diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 6f74fbb..3d0e9c3 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -1,3 +1,17 @@ +## 2026-10-08:全站前端制作流程重构 + +用户批准完整重构方案后,从 GitHub master 的 cbd55c1 创建独立 codex/ui-workflow-refactor 工作树,保护主目录原有文档与架构图修改。并行实施公共页壳/素材配置、状态投影与任务查询、发送中心编辑和预览;根代理负责审片、字幕保存、脚本拆分与整合验收。 + +五个一级入口、浅色公共组件、真实搜索与分页、任务阶段/人工待办/发布独立状态、三栏审片、字幕保存前切轨、紧凑发布编辑器与独立选择范围已落地。新增AI建议预览、版本冲突和动态策略影响预览,沿用当前抖音前台范围。无数据库迁移,不改既有排期、不代用户确认成片或发送。 + +第一轮74项成片/异步切片/任务刷新/安全回归通过;新增字幕立即切轨、慢保存、409保留输入3项Chrome验证通过;36候选在5种宽度的主操作可见性与单编辑器验证通过。实际截图发现并修正发送页说明占首屏、详情空白列、桌面多余菜单与分页换行。全量回归、CI与正式部署结果补在最新交付条目中。 + +整合后继续以正式库的只读SQLite备份检查64任务和200作品。修复实际1024/手机操作离屏、设置固定五列和长路径溢出、长标题挤压动作、发布0%混入制作进度、排期深链误提示;字幕审核/AI/导入与批量操作统一锁定当前轨。编辑失败仍保留输入,封面生成只前进已提交字段基准,生成期间新增标题保留。全量1573通过的6个失败均为旧断言;相应模块79项fresh通过,另记录新补回归与独立审查结果。页面脚本与HTML/CSS字节下降,服务端状态校验的耗时增加已如实记录,不宣称整体响应提速。辅助原文/视觉证据返回保持任务筛选条件。 + +任务页进一步取消旧全量格式化和逐任务成片count,仅轻量读取任务原始行,先真实查询/排序/分页,再按当前页25条校验人工审核凭据;阶段筛选保留完整匹配集,总数与页面API契约不变。成片数量与状态投影共用一个连接,旧首页/list_tasks及所有人工审核、冻结字幕、当前轨/verified/文件校验保持原样。60项后端查询/投影/发送关联与2项1440/390 Chrome fresh通过,新增测试约束计算范围、单次批量计数、已删除与历史版本过滤及标签/日期兼容。 + +原先两轮HTTP中位499→854ms、872→1336ms仍保留为负载波动下的变慢证据。同进程同一只读64任务快照交替各5次函数profile,优化前后均值911.25→441.04ms(下降51.6%)、中位852.08→371.87ms,SQLite连接83→1;25次manifest完整保留,查询结果逐项相同、快照SHA不变、无初始化/迁移或写入。最新预览GET中位635ms(正式服务3723ms),配置/媒体目录与负载不同,不宣称HTTP差值为84%提速。原始profile、HTTP与浏览器证据保留在忽略目录data/acceptance/ui-refactor;当前仍待最终CI、合并与正式部署。 + ## 2026-10-02:排期文案改写与生成机制调整 按用户要求检查正式 8001 和 8765:正式运行目录仍为 offline-runtime,数据库由 readiness 确认为主目录 data/workflow.sqlite3。快照记录 187 条 SCHEDULED,全部取得对应片段范围内的台词;在线备份及原文保存在忽略目录 data/operations/20261002-copy-rewrite。 @@ -2118,3 +2132,15 @@ PR #142 三项 CI 全通过,合并为 `5f6e22e`,原 RunDock Web 8001 更新 ## 2026-09-27 综艺播放目标正式接入 按用户明确授权,将候选方案接入正式三阶段选片。Profile允许旧、新两个受支持哈希,分析器逐请求使用冻结Profile,新Checkpoint包含规则哈希;旧版本执行不改变。新增显式幂等启用/回退工具,在同一事务创建专用Prompt及不可变版本后切换头指针,保留历史数据。新权重25/20/20/20/10/5,取消旧分数门槛及声音加分,改结构化证据门槛后排序;自然45—150秒不补60秒。审核页展示六维、门槛依据及视听待核,创建页展示激活后的名称。无表结构变更或自动发布。部署和实际验收另行留证。 + +## 2026-10-08:CI 浏览器测试隔离清理修复 + +PR #155 首轮 Linux CI 的1514项通过、75项跳过,六项生命周期清理因外键报错。明确污染源为新增发布工作区fixture在缺少Chrome时先创建关联记录,再在finally外skip;修复为先检查浏览器,setup/launch失败也进入外层清理。只改测试fixture,不兜底删除其他测试记录。模拟缺Chrome前序组合22通过/12跳过,正常Chrome前序组合34通过,新增两项异常回归与Ruff/差异检查通过。正式服务尚未更新,最终提交须重新通过所有CI。 + +## 2026-10-08:密集排期的最终响应式复核 + +正式库隔离副本的144条排期在1280宽度定位后发现计划列最小宽度撑出横向滚动,按侧栏可用宽度调整为可收缩桌面列、中屏三列、手机单列信息;定位不横向滚动,取消操作使用统一按钮。手机长标题独占下一行并完整换行。五宽度的12条任务排期、自动定位、批量栏、月历和其他标签fresh5通过;原始截图在忽略验收目录。根浏览器对真实副本144条在1280/390复核文档宽度、scrollX=0、目标标记12条和按钮实际可见,无业务写入。 + +## 2026-10-08:公共文字操作按钮兼容收尾 + +迁移公共样式时补齐仍被页面使用的text-button/link-button,兼容链接与按钮,统一透明底、hover、danger和disabled;桌面38px、手机/触控44px,删除旧基础冲突及Worker重新检测局部压缩。页壳与五宽度发送布局fresh10通过;根手机实际内容准备页复核按钮外观、任务范围与无横向滚动。正式环境尚待最终CI与更新。 diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index cf93647..f081a94 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,3 +1,11 @@ +## 2026-10-08:全站重构交付与使用 + +已按批准方案完成工作台、素材库、制作任务、发送中心、内容复盘与设置的统一结构。新导航中,先在素材库选择素材或新建制作;需要处理的事项在工作台直达;审片和字幕从对应任务继续;发送中心分别完成内容准备、排期与结果复核。 + +本轮交付后继续由用户审片、审核字幕、接受AI文案、选择排期和确认发布结果。编辑冲突时输入仍保留,请先核对服务器版本再重试。动态策略必须查看影响预览后确认;页面刷新不会启用策略或改写文案。工程检查、PR/CI和运行验收以 PROJECT_STATUS 最新记录为准。 + +实现及本地界面验收已完成,下一步按既有流程核对最终提交CI、合并、在线备份与原RunDock Web更新。Worker、生产数据库、配置和既有排期沿用原目标。任务页已取消全量文件探测和逐条成片查询,同进程只读快照函数profile均值911.25→441.04ms、中位852.08→371.87ms,连接83→1;当前页25条人工审核manifest与冻结字幕/当前轨/verified/文件校验仍完整执行。62项关联及真实浏览器回归通过。保留此前HTTP偏慢记录;新版隔离预览GET中位635ms只作为实际观察,环境与负载不同,不解释为正式服务的整体提速。正式性能和关键界面将在最终CI通过并部署后复核。 + ## 2026-10-02:发布文案已交付 - 187条既有排期标题、简介和3个话题已全部改写并验证,排期与成片保留;刷新发送中心查看。 diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 6b93871..95c890e 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,3 +1,13 @@ +## 全站前端重构:本地验收完成,等待 CI 与正式更新(2026-10-08) + +从 master cbd55c1 的独立 codex/ui-workflow-refactor 工作树实施用户批准的全站方案。原主工作目录的已有修改保留。继续Jinja2/原生JS;无数据库迁移、配置迁移或生产记录写入。 + +全站导航与浅色页壳、共享制作配置、统一状态、真实任务查询、审片工作区、字幕可靠保存、发布建议预览和冲突保护、动态策略影响确认、复盘/设置二级导航均已实施。独立审查发现的字幕响应串轨、封面保存版本冲突、补齐设置未打开编辑器、旧任务跳过字幕绑定问题均已修复。 + +本地全量运行 1573 项通过、6 项旧结构/文案断言失败;适配新结构后,对应完整模块 79 项重新通过。另有发送中心92项、任务/投影34项、辅助详情32项及最终页壳/定位14项分区回归通过;实际Chrome覆盖字幕慢保存/409/审核切轨、36候选单编辑器、共享Prompt作用范围、AI建议与版本冲突等。64任务和200作品的隔离副本在1440/1280/1024/768/390宽度完成实际检查,设置长路径与复盘操作离屏问题修复后复核通过。Ruff、Python编译与全部JS语法通过。详细范围及性能限制见 docs/acceptance/ui-refactor-2026-10-08.md。最终CI、合并、正式服务更新以随后交付记录为准,不以源码或隔离页面代表部署完成。 + +本轮正式目标仍为原RunDock Niuma-Studio Web8001、offline-runtime运行目录、主目录data/workflow.sqlite3,以及既有Worker8765。正式更新前保留在线备份和代码回滚版本;不代用户审片、改期、接受AI建议或发布内容。 + ## 当前:187条排期文案已改写并部署(2026-10-02 19:45) [PR #153](https://github.com/damingishere-coder/Ai-Clip-Workflow/pull/153) 最终提交 `0eddb86` 的 Linux、Windows、Docker CI 全通过,Squash 合并为 `ac7bf5e7f183e7000bc2a163bc0d73c0836c5e38`。本地完整1535项离线回归通过,最终文案/保存/就绪37项补测通过,Ruff、Python编译、JS语法通过,无阻塞审查。 diff --git a/app/models/task.py b/app/models/task.py index 499d952..eb370b6 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -318,6 +318,7 @@ def validate_output_clip_ids(cls, value: list[str]) -> list[str]: class PublishCoverCreate(BaseModel): + expected_updated_at: Optional[str] = Field(default=None, max_length=100) task_id: str = Field(..., min_length=1, max_length=80) output_clip_id: str = Field(..., min_length=1, max_length=80) video_source: Literal["original", "subtitled"] = "original" @@ -326,6 +327,9 @@ class PublishCoverCreate(BaseModel): class PublishSendJobUpdate(BaseModel): + expected_updated_at: Optional[str] = Field(default=None, max_length=100) + account_id: Optional[str] = Field(default=None, max_length=120) + publish_mode: Optional[Literal["local_browser", "manual_export"]] = None title: str = Field(..., min_length=1, max_length=120) description: Optional[str] = Field(default="", max_length=2000) tags: Optional[str] = Field(default="", max_length=500) @@ -474,6 +478,8 @@ class AdaptivePolicyUpdate(BaseModel): daily_start_time: str = "07:00" daily_end_time: str = "23:59" include_existing: bool = False + preview_token: Optional[str] = Field(default=None, max_length=100) + confirmed: bool = False class AdaptiveJobUpdate(BaseModel): diff --git a/app/routers/pages.py b/app/routers/pages.py index 272ced7..1862dac 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -1,7 +1,10 @@ from pathlib import Path +import re +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from fastapi import APIRouter, HTTPException, Request, Query from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse from app.core.config import settings from app.services.ai_prompt_preset_service import list_ai_prompt_presets, get_ai_prompt_preset @@ -17,19 +20,19 @@ get_subtitle_task_context, get_subtitle_workflow_context, get_system_status_context, + get_tasks_page_context, ) +from app.services.ui_projection_service import task_projections from app.services.task_service import ( get_artifact_paths, get_latest_ai_analysis_run, get_task, - get_task_workflow_steps, get_transcript_preview, get_workflow_steps, list_clip_candidates, list_ai_analysis_runs, list_output_clips, list_task_name_history, - list_tasks, ) @@ -37,9 +40,45 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[1] / "templates")) +def _safe_return_to(value): + """Only a local task-list location can be carried between workspaces.""" + value = str(value or '') + if len(value) > 1500 or any(ord(char) < 32 for char in value): + return '/tasks' + try: + parsed = urlsplit(value) + except ValueError: + return '/tasks' + if parsed.scheme or parsed.netloc or parsed.path != '/tasks' or parsed.fragment: + return '/tasks' + params = [(key, val) for key, val in parse_qsl(parsed.query) if key in {'q', 'platform', 'stage', 'sort', 'page'}] + return '/tasks' + ('?' + urlencode(params) if params else '') + + +def _with_return_to(url, return_to): + parsed = urlsplit(url) + query = [(key, val) for key, val in parse_qsl(parsed.query) if key != 'return_to'] + query.append(('return_to', return_to)) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)) + + +def _task_navigation(request, task_id): + return_to = _safe_return_to(request.query_params.get('return_to')) + return dict(return_to=return_to, task_detail_url=_with_return_to(f'/tasks/{task_id}', return_to), + review_url=_with_return_to(f'/tasks/{task_id}/clips/review', return_to), + subtitle_url=_with_return_to(f'/subtitles/{task_id}', return_to), + publish_url=_with_return_to(f'/publish?task_id={task_id}&tab=content', return_to), + transcript_url=_with_return_to(f'/tasks/{task_id}/transcript', return_to), + visual_url=_with_return_to(f'/tasks/{task_id}/visual-evidence', return_to)) + + @router.get("/review-inbox") async def review_inbox_page(request: Request, category: str = 'all', page: int = Query(1, ge=1)): - from app.services.production_workbench_service import inbox + from app.services.production_workbench_service import CATEGORIES, inbox + if category not in {'all', *CATEGORIES}: + raise HTTPException(status_code=400, detail='待办分类无效') + if request.query_params.get('embedded') != '1': + return RedirectResponse('/materials?' + urlencode(dict(view='inbox', category=category, page=page)), status_code=303) try: context = inbox(category, page) except ValueError as exc: @@ -64,11 +103,23 @@ async def dashboard(request: Request): @router.get("/tasks") -async def tasks_page(request: Request): - tasks = list_tasks() +def tasks_page(request: Request, q: str = Query('', max_length=300), platform: str = 'all', + stage: str = 'all', sort: str = 'created_desc', page: int = Query(1, ge=1)): + try: + context = get_tasks_page_context(q=q, platform=platform, stage=stage, sort=sort, page=page) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + tasks = context['tasks'] + return_to = '/tasks?' + urlencode({**context['filters'], 'page':context['pagination']['page']}) + context['return_to'] = return_to link_states = get_publish_link_states([task["id"] for task in tasks]) for task in tasks: task["publish_link_state"] = link_states.get(task["id"], {}) + task['navigation'] = dict(detail=_with_return_to(f"/tasks/{task['id']}", return_to), + primary=_with_return_to(task['ui']['primary_action']['url'], return_to), + review=_with_return_to(f"/tasks/{task['id']}/clips/review", return_to), + subtitle=_with_return_to(f"/subtitles/{task['id']}", return_to), + publish=_with_return_to(f"/publish?task_id={task['id']}&tab=content", return_to)) return templates.TemplateResponse( name="tasks.html", request=request, @@ -76,11 +127,23 @@ async def tasks_page(request: Request): "request": request, "active_page": "tasks", "settings": settings, - "tasks": tasks, + **context, }, ) +@router.get('/api/ui/tasks') +def ui_tasks(q: str = Query('', max_length=300), platform: str = 'all', stage: str = 'all', + sort: str = 'created_desc', page: int = Query(1, ge=1)): + try: + context = get_tasks_page_context(q=q, platform=platform, stage=stage, sort=sort, page=page) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + context['tasks'] = [{key: task[key] for key in ('id', 'title', 'platform', 'platform_label', 'created_at', 'ui')} + for task in context['tasks']] + return context + + @router.get("/tasks/new") async def new_task_page(request: Request, challenger_id: str = ""): from app.services.content_profile_service import list_content_profiles @@ -118,6 +181,10 @@ async def task_detail_page(request: Request, task_id: str): task = get_task(task_id) if not task: raise HTTPException(status_code=404, detail="任务不存在") + task['ui'] = task_projections([task_id])[task_id] + navigation = _task_navigation(request, task_id) + task['ui']['primary_action'] = {**task['ui']['primary_action'], + 'url':_with_return_to(task['ui']['primary_action']['url'], navigation['return_to'])} from app.db.database import get_connection from app.services.production_review_service import requires_review @@ -129,6 +196,18 @@ async def task_detail_page(request: Request, task_id: str): trial = task_trial(task_id) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + with get_connection() as connection: + frozen_row = connection.execute(''' + SELECT v.preset_id,v.preset_name_snapshot AS name,v.prompt_text,v.version_number,v.prompt_sha256 + FROM ai_analysis_runs r JOIN ai_prompt_versions v ON v.id=r.prompt_version_id + WHERE r.task_id=? ORDER BY r.run_number DESC LIMIT 1 + ''', (task_id,)).fetchone() + if frozen_row is None: + frozen_row = connection.execute(''' + SELECT f.preset_id,p.name,f.prompt_text,NULL AS version_number + FROM task_generation_rules f LEFT JOIN ai_prompt_presets p ON p.id=f.preset_id WHERE f.task_id=? + ''', (task_id,)).fetchone() + frozen_prompt = dict(frozen_row) if frozen_row else None return templates.TemplateResponse( name="task_detail.html", @@ -138,13 +217,15 @@ async def task_detail_page(request: Request, task_id: str): "active_page": "tasks", "settings": settings, "task": task, + **navigation, "production_review_required": is_batch, "publish_link_state": get_task_publish_link_state(task_id), - "workflow_steps": get_task_workflow_steps(task), + "workflow_steps": task['ui']['workflow_steps'], "transcript_lines": get_transcript_preview(task_id), "output_clips": list_output_clips(task_id), "ai_prompt_presets": [get_ai_prompt_preset(trial["challenger_preset_id"])] if trial else list_ai_prompt_presets(), "challenger_trial": trial, + "frozen_prompt": frozen_prompt, "current_prompt_preset": get_ai_prompt_preset(task.get("ai_prompt_preset_id") or "preset_001"), "latest_ai_analysis_run": get_latest_ai_analysis_run(task_id), "ai_analysis_runs": list_ai_analysis_runs(task_id), @@ -171,6 +252,7 @@ async def task_transcript_page(request: Request, task_id: str): "active_page": "tasks", "settings": settings, "task": task, + **_task_navigation(request, task_id), "transcript_path": str(transcript_path), "transcript_text": transcript_text, "transcript_exists": transcript_path.exists(), @@ -224,6 +306,7 @@ async def _render_clip_review_page( "settings": settings, "task": task, "production_review_required": review_required, + **_task_navigation(request, task_id), "clips": visible_clips, "clip_count": len(all_clips), "enabled_clip_count": sum(1 for clip in all_clips if clip["enabled"]), @@ -299,6 +382,7 @@ async def subtitle_task_page(request: Request, task_id: str): "active_page": "subtitles", "settings": settings, "subtitle_task_mode": True, + **_task_navigation(request, task_id), "publish_link_state": get_task_publish_link_state(task_id), **context, }, @@ -308,6 +392,9 @@ async def subtitle_task_page(request: Request, task_id: str): @router.get("/publish") def publish_center_page(request: Request): focus_task_id = request.query_params.get("task_id", "") + focus_job_id = request.query_params.get('job_id', '') + if focus_job_id and not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', focus_job_id): + raise HTTPException(status_code=400, detail='发布记录定位参数无效') return templates.TemplateResponse( name="publish.html", request=request, @@ -317,6 +404,7 @@ def publish_center_page(request: Request): "settings": settings, "publish_message": request.query_params.get("publish_message", ""), "focus_task_id": focus_task_id, + "focus_job_id": focus_job_id, "focus_platform": "douyin", "focus_tab": request.query_params.get("tab", ""), **get_publish_center_context(focus_task_id=focus_task_id), @@ -389,4 +477,5 @@ async def visual_evidence_page(request: Request, task_id: str, run_id: str | Non summary["global_reason"] = (meta.get("global_judge") or {}).get("reason") summary["unavailable"] = [{"source_id": key, "reason": value.get("failure_reason")} for key, value in (meta.get("candidates") or {}).items() if value.get("status") == "unavailable"] return templates.TemplateResponse(name="visual_evidence.html", request=request, - context={"request": request, "active_page": "tasks", "settings": settings, "task": task, "run_id": run_id or "", "visual_summary": summary}) + context={"request": request, "active_page": "tasks", "settings": settings, "task": task, "run_id": run_id or "", "visual_summary": summary, + **_task_navigation(request, task_id)}) diff --git a/app/routers/publish.py b/app/routers/publish.py index 5963706..bede7d6 100644 --- a/app/routers/publish.py +++ b/app/routers/publish.py @@ -139,6 +139,7 @@ def list_publish_history_records( deleted: bool = Query(default=False), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=50), + job_id: str = Query(default="", max_length=128, pattern=r"^[A-Za-z0-9_-]*$"), ) -> dict: try: return publish_service.list_publish_history_records( @@ -148,6 +149,7 @@ def list_publish_history_records( deleted=deleted, page=page, page_size=page_size, + job_id=job_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -286,6 +288,8 @@ async def generate_publish_cover_frames(payload: PublishCoverFrameBatchCreate) - async def generate_publish_job_cover(job_id: str, payload: PublishCoverCreate) -> dict: try: return publish_service.generate_publish_job_cover(job_id, payload) + except publish_service.PublishContentConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -294,6 +298,8 @@ async def generate_publish_job_cover(job_id: str, payload: PublishCoverCreate) - async def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: try: return publish_service.update_send_job(job_id, payload) + except publish_service.PublishContentConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -314,6 +320,14 @@ async def regenerate_send_job_metadata(job_id: str, use_ai: bool = Query(default raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/jobs/{job_id}/metadata/preview") +async def preview_send_job_metadata(job_id: str) -> dict: + try: + return await run_in_threadpool(publish_service.preview_send_job_metadata, job_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/jobs/{job_id}/retry") async def retry_publish_job( job_id: str, @@ -557,16 +571,36 @@ async def adaptive_schedule_context(account_id: str = "") -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/schedules/adaptive/{account_id}/preview") +async def preview_adaptive_policy(account_id: str, payload: AdaptivePolicyUpdate) -> dict: + from app.services import adaptive_schedule + try: + values = payload.model_dump(exclude={"include_existing", "preview_token", "confirmed"}) + return await run_in_threadpool(adaptive_schedule.preview_policy, account_id, values, payload.include_existing) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.patch("/schedules/adaptive/{account_id}") async def update_adaptive_policy(account_id: str, payload: AdaptivePolicyUpdate) -> dict: from app.services import adaptive_schedule try: - values = payload.model_dump(exclude={"include_existing"}) - return {"policy": adaptive_schedule.save_policy(account_id, values, payload.include_existing)} + values = payload.model_dump(exclude={"include_existing", "preview_token", "confirmed"}) + return {"policy": adaptive_schedule.save_policy(account_id, values, payload.include_existing, + preview_token=payload.preview_token, confirmed=payload.confirmed)} + except adaptive_schedule.ScheduleConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/schedules/adaptive/{account_id}/apply-preview") +async def apply_adaptive_policy_preview(account_id: str, payload: AdaptivePolicyUpdate) -> dict: + if not payload.preview_token or not payload.confirmed: + raise HTTPException(status_code=409, detail="请先生成影响预览并明确确认。") + return await update_adaptive_policy(account_id, payload) + + @router.patch("/jobs/{job_id}/adaptive") async def update_adaptive_job(job_id: str, payload: AdaptiveJobUpdate) -> dict: from app.services import adaptive_schedule diff --git a/app/services/adaptive_schedule.py b/app/services/adaptive_schedule.py index 3019e0a..d7a9ba6 100644 --- a/app/services/adaptive_schedule.py +++ b/app/services/adaptive_schedule.py @@ -203,10 +203,74 @@ def _save_policy(connection, account_id, options, include_existing=False): return policy(connection, account_id) -def save_policy(account_id, options, include_existing=False): +def _policy_impact(connection, account_id, options, include_existing=False): + current = policy(connection, account_id) + values = validate_options({**current, **options}) + now = _now() + rows = _jobs(connection, account_id) + scores = score_times(connection, account_id, now) + protected = [] + managed = [] + newly_managed = [] + for row in rows: + time = _time(row) if row["status"] == "SCHEDULED" and row.get("scheduled_at") else None + reason = "" + if row["status"] != "SCHEDULED": + reason = "不是待执行排期" + elif row.get("claimed_at") or row.get("needs_manual_review"): + reason = "已执行或待人工复核" + elif row.get("adaptive_fixed"): + reason = "人工固定时间" + elif not time or time.date() <= now.date(): + reason = "当天及已过时间保持不变" + if reason: + protected.append({"job_id": row["id"], "title": row.get("title") or row["id"], "reason": reason}) + elif row.get("adaptive_managed") or include_existing: + managed.append(row) + if not row.get("adaptive_managed"): + newly_managed.append(row["id"]) + managed.sort(key=lambda row: (_time(row), row["created_at"], row["id"])) + schedule = [] + if values["enabled"] and scores["ready"] and managed: + tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + schedule = allocate(managed, rows, values, scores, tomorrow, now) + by_id = {row["id"]: row for row in rows} + for item in schedule: + row = by_id[item["job_id"]] + item.update(title=row.get("title") or row["id"], old_time=row.get("scheduled_at")) + snapshot = [ + {key: row.get(key) for key in ("id", "status", "updated_at", "scheduled_at", "adaptive_managed", "adaptive_fixed", "claimed_at", "needs_manual_review")} + for row in sorted(rows, key=lambda row: row["id"]) + ] + token = hashlib.sha256(_json([account_id, current, values, bool(include_existing), now.date().isoformat(), snapshot, scores]).encode()).hexdigest() + return {"account_id": account_id, "preview_token": token, "before": current, "after": values, + "newly_managed_count": len(newly_managed), "managed_count": len(managed), + "newly_managed_ids": newly_managed, + "protected_count": len(protected), "protected": protected, "schedule": schedule, + "message": "停用只保留当前时间。" if not values["enabled"] else scores["reason"], + "include_existing": bool(include_existing)} + + +def preview_policy(account_id, options, include_existing=False): + with get_connection() as connection: + return _policy_impact(connection, account_id, options, include_existing) + + +def save_policy(account_id, options, include_existing=False, *, preview_token=None, confirmed=False): with get_connection() as connection: connection.execute("BEGIN IMMEDIATE") - result = _save_policy(connection, account_id, options, include_existing) + if preview_token is not None: + if not confirmed: + raise ScheduleConflict("请核对影响预览并明确确认。") + current_preview = _policy_impact(connection, account_id, options, include_existing) + if preview_token != current_preview["preview_token"]: + raise ScheduleConflict("账号策略或排期已变化,请重新预览后确认。") + # The reviewed UI only enrolls the future jobs named in its impact preview. + # Legacy callers keep the existing include_existing behavior. + result = _save_policy(connection, account_id, options, include_existing and preview_token is None) + if preview_token is not None and options.get("enabled") and include_existing: + for job_id in current_preview["newly_managed_ids"]: + connection.execute("UPDATE publish_jobs SET adaptive_managed=1,updated_at=? WHERE id=?", (to_utc_iso(_now()), job_id)) connection.commit() return result diff --git a/app/services/production_workbench_service.py b/app/services/production_workbench_service.py index 5d1733c..47c488e 100644 --- a/app/services/production_workbench_service.py +++ b/app/services/production_workbench_service.py @@ -4,6 +4,7 @@ from app.core.config import settings from app.db.database import get_connection from app.services.publish_time import app_zone, parse_datetime +from app.services.ui_projection_service import load_task_rows, task_projections CATEGORIES = { 'start': ('待继续处理', '任务'), @@ -12,89 +13,32 @@ 'errors': ('异常任务', '任务'), } -TASK_SUMMARY = """ -WITH candidate AS ( - SELECT task_id,count(*) n FROM clip_candidates WHERE is_deleted=0 GROUP BY task_id -), outputs AS ( - SELECT oc.task_id,count(*) n, - sum(CASE WHEN EXISTS(SELECT 1 FROM subtitle_jobs sj - JOIN subtitle_tracks st ON st.output_clip_id=oc.id AND st.task_id=oc.task_id - AND st.is_active=1 AND st.active_revision_id=sj.revision_id - JOIN subtitle_revisions sr ON sr.id=sj.revision_id AND sr.track_id=st.id AND sr.status='approved' - WHERE sj.output_clip_id=oc.id AND sj.task_id=oc.task_id AND sj.is_active=1 - AND sj.status='completed' AND sj.validation_status='verified' - AND COALESCE(sj.output_file_path,'')!='') THEN 0 ELSE 1 END) subtitle_pending, - sum(CASE WHEN EXISTS(SELECT 1 FROM publish_jobs p WHERE p.output_clip_id=oc.id - AND p.status!='CANCELLED') THEN 0 ELSE 1 END) unprepared - FROM output_clip oc WHERE oc.is_active=1 AND oc.status='completed' GROUP BY oc.task_id -), latest_job AS ( - SELECT task_id,max(rowid) latest FROM workflow_jobs GROUP BY task_id -), latest_review AS ( - SELECT task_id,max(rowid) latest FROM production_reviews GROUP BY task_id -) -SELECT t.id,t.task_name title,t.status,t.updated_at,COALESCE(cc.n,0) candidates, - COALESCE(o.n,0) outputs,COALESCE(o.subtitle_pending,0) subtitle_pending, - COALESCE(o.unprepared,0) unprepared,b.id batch_item,b.batch_id, - j.status job_status,j.job_type,j.message job_message,j.error_message job_error, - EXISTS(SELECT 1 FROM workflow_jobs w WHERE w.task_id=t.id AND w.status IN ('queued','running')) busy, - EXISTS(SELECT 1 FROM workflow_jobs w WHERE w.task_id=t.id AND w.status='running') running, - r.delivery_mode,CASE WHEN r.id IS NOT NULL AND r.revision=e.revision - AND EXISTS(SELECT 1 FROM cut_runs cr WHERE cr.id=r.cut_run_id AND cr.task_id=t.id - AND cr.is_active=1 AND cr.status='completed') THEN 1 ELSE 0 END approved -FROM tasks t LEFT JOIN candidate cc ON cc.task_id=t.id LEFT JOIN outputs o ON o.task_id=t.id -LEFT JOIN material_batch_items b ON b.task_id=t.id -LEFT JOIN latest_job lj ON lj.task_id=t.id LEFT JOIN workflow_jobs j ON j.rowid=lj.latest -LEFT JOIN latest_review lr ON lr.task_id=t.id LEFT JOIN production_reviews r ON r.rowid=lr.latest -LEFT JOIN production_review_epochs e ON e.task_id=t.id -WHERE COALESCE(t.is_deleted,0)=0 -""" - - -def _classify(row): - """One task issue at a time. Automatic reviewed flags are intentionally ignored.""" - if row['busy']: - return None - status = row['status'].lower() - if status in {'failed', 'completed_with_errors'} or status.startswith('failed_') or row['job_status'] == 'failed': - return 'errors', 1, row['job_error'] or '处理未完成,请查看任务中的失败步骤和恢复提示', ( - f"/materials?batch={row['batch_id']}" if row['job_type']=='material_import' else f"/tasks/{row['id']}") - if row['batch_item']: - if row['outputs'] and not row['approved']: - return 'clips', row['outputs'], '查看实际成片并确认;版本变化后需要重新核对', f"/tasks/{row['id']}/clips" - if row['approved'] and row['delivery_mode'] == 'subtitled' and row['subtitle_pending']: - return 'subtitles', row['subtitle_pending'], '完成字幕审核与渲染验证', f"/subtitles/{row['id']}" - if row['approved'] and row['unprepared']: - return 'prepare', row['unprepared'], '已确认交付方式,请明确进入内容准备', f"/tasks/{row['id']}/clips" - elif status == 'pending_review' and row['candidates']: - return 'clips', row['candidates'], '核对 AI 候选和选择;默认保留不代表人工认可', f"/tasks/{row['id']}/clips" - elif status == 'pending_subtitle_review' and row['outputs']: - return 'subtitles', row['subtitle_pending'] or row['outputs'], '核对字幕版本和交付选择', f"/subtitles/{row['id']}" - if status in {'pending_processing', 'pending_ai'}: - return 'start', 1, '素材已就绪,请在任务详情继续转写或 AI 分析', f"/tasks/{row['id']}" - return None - - def _projection(c): - rows = [dict(r) for r in c.execute(TASK_SUMMARY)] + rows = load_task_rows(c) + projections = task_projections(connection=c, rows=rows) items = [] for row in rows: - issue = _classify(row) - if issue: - category, count, message, url = issue + ui = projections[row['id']] + row.update(ui=ui, running=ui['running'], busy=ui['busy']) + if ui['category']: + category, count, message, url = ui['category'], ui['count'], ui['message'], ui['primary_action']['url'] items.append(dict(id='task:'+row['id'], task_id=row['id'], title=row['title'], - category=category, count=count, message=message, url=url, updated_at=row['updated_at'])) + category=category, count=count, unit=CATEGORIES[category][1], + message=message, url=url, updated_at=row['updated_at'])) for row in c.execute("""SELECT p.id,p.task_id,p.title,p.updated_at,p.status,t.task_name task_title FROM publish_jobs p JOIN tasks t ON t.id=p.task_id - WHERE p.status IN ('NEED_REVIEW','FAILED') AND COALESCE(t.is_deleted,0)=0"""): + WHERE p.status IN ('NEED_REVIEW','FAILED') AND p.platform='douyin' AND COALESCE(t.is_deleted,0)=0"""): items.append(dict(id='publish:'+row['id'], task_id=row['task_id'], title=row['title'] or row['task_title'], category='publish', count=1, message=('发送失败,请查看失败原因后决定是否重试' if row['status']=='FAILED' else '发送结果或条件需要人工复核,请查看执行记录'), - url='/publish', updated_at=row['updated_at'])) + unit='发布任务', url=f"/publish?task_id={row['task_id']}&job_id={row['id']}&tab=history", updated_at=row['updated_at'])) counts = {key: dict(label=label, unit=unit, count=0, tasks=0) for key,(label,unit) in CATEGORIES.items()} for key in counts: subset = [item for item in items if item['category'] == key] counts[key].update(count=sum(i['count'] for i in subset), tasks=len({i['task_id'] for i in subset})) + priority = {'publish': 0, 'errors': 1, 'clips': 2, 'subtitles': 3, 'prepare': 4, 'start': 5} items.sort(key=lambda i:(i['updated_at'] or '',i['id']), reverse=True) + items.sort(key=lambda i:priority[i['category']]) return rows, items, counts @@ -130,28 +74,35 @@ def dashboard(*, now=None): today = (now.replace(tzinfo=zone) if now.tzinfo is None else now.astimezone(zone)).date() with get_connection() as c: c.execute('BEGIN') - rows,_,counts = _projection(c) + rows,items,counts = _projection(c) materials = c.execute('SELECT count(*) FROM source_materials').fetchone()[0] - scheduled, pending = 0,0 + scheduled, pending, all_scheduled = 0,0,0 for job in c.execute("""SELECT p.status,p.scheduled_at FROM publish_jobs p JOIN tasks t ON t.id=p.task_id - WHERE COALESCE(t.is_deleted,0)=0 AND p.status IN ('DRAFT','WAITING','SCHEDULED','PUBLISHING','PUBLISHED')"""): + JOIN output_clip o ON o.id=p.output_clip_id AND o.task_id=p.task_id + WHERE COALESCE(t.is_deleted,0)=0 AND o.is_active=1 AND p.platform='douyin' + AND p.status IN ('DRAFT','WAITING','SCHEDULED','PUBLISHING','PUBLISHED')"""): pending += job['status'] in {'DRAFT','WAITING'} + all_scheduled += job['status'] == 'SCHEDULED' if job['scheduled_at'] and job['status'] in {'SCHEDULED','PUBLISHING','PUBLISHED'}: try: scheduled += parse_datetime(job['scheduled_at'],settings.app_timezone).astimezone(zone).date() == today except ValueError: pass # Invalid historical time is not a fabricated schedule. works,note = _reviewable(c) - cards = [dict(label='素材池',value=materials,note='已登记素材',url='/materials'), - dict(label='处理中',value=sum(r['running'] for r in rows), + cards = [dict(key='total_tasks',label='制作任务',value=len(rows),note='个有效制作任务',url='/tasks'), + dict(key='materials',label='素材池',value=materials,note='已登记素材',url='/materials'), + dict(key='processing',label='制作中',value=sum(r['busy'] for r in rows), note=f"另 {sum(bool(r['busy']) and not r['running'] for r in rows)} 个任务排队中 · {counts['start']['count']} 个待继续处理",url='/tasks')] for key in ('clips','subtitles'): value = counts[key] - cards.append(dict(label=value['label'],value=value['count'],note=f"{value['unit']} · {value['tasks']} 个任务",url='/review-inbox?category='+key)) + cards.append(dict(key='clip_review' if key == 'clips' else 'subtitle_review',label=value['label'],value=value['count'],note=f"{value['unit']} · {value['tasks']} 个任务",url='/materials?view=inbox&category='+key)) cards.extend([ - dict(label='待发布',value=pending,note=f"发布任务 · 另 {counts['prepare']['count']} 条切片待内容准备",url='/publish'), - dict(label='今日排期',value=scheduled,note=f'{today} · 上海时间',url='/publish'), - dict(label='可复盘作品',value=works,note=note,url='/content-review'), - dict(label='异常任务',value=counts['errors']['count'],note=f"另 {counts['publish']['count']} 个发布任务需复核",url='/review-inbox?category=errors'), + dict(key='publish_pending',label='待安排发布',value=pending,note=f"条发布记录 · 另 {counts['prepare']['count']} 条切片待内容准备",url='/publish?tab=content'), + dict(key='scheduled',label='已排期',value=all_scheduled,note='条发布记录 · 抖音',url='/publish?tab=schedule'), + dict(key='today_schedule',label='今日排期',value=scheduled,note=f'{today} · 上海时间',url='/publish'), + dict(key='reviewable',label='可复盘作品',value=works,note=note,url='/content-review'), + dict(key='production_error',label='制作异常',value=counts['errors']['count'],note='个制作任务',url='/materials?view=inbox&category=errors'), + dict(key='publish_attention',label='发布需处理',value=counts['publish']['count'],note='条发布记录 · 失败或需复核',url='/materials?view=inbox&category=publish'), ]) - return dict(cards=cards,counts=counts) + return dict(cards=cards,counts=counts, + needs_attention=dict(total=len(items), items=items[:8], counts=counts)) diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 99b3dea..b1e9b22 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -2777,14 +2777,18 @@ def generate_publish_cover(payload: PublishCoverCreate, job_id: str | None = Non if job_id: with get_connection() as connection: - connection.execute( + cursor = connection.execute( """ UPDATE publish_jobs SET cover_mode = 'time', cover_time_seconds = ?, cover_file_path = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND status IN ('DRAFT', 'WAITING', 'SCHEDULED') + AND (? IS NULL OR updated_at = ?) """, - (float(payload.cover_time_seconds or 0), str(cover_path), _now_iso(), job_id), + (float(payload.cover_time_seconds or 0), str(cover_path), _version_iso(), job_id, payload.expected_updated_at, payload.expected_updated_at), ) + if not cursor.rowcount: + connection.rollback() + raise PublishContentConflict("封面生成期间内容或状态已变化,请保留输入并核对服务器版本。") connection.commit() return { @@ -2801,6 +2805,11 @@ def generate_publish_job_cover(job_id: str, payload: PublishCoverCreate) -> dict raise ValueError("发布任务不存在。") if job.get("task_id") != payload.task_id or job.get("output_clip_id") != payload.output_clip_id: raise ValueError("封面参数和发布任务不一致。") + if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: + raise ValueError("只有草稿、等待或已排期任务可以更换封面。") + if payload.expected_updated_at is not None and payload.expected_updated_at != job.get("updated_at"): + raise PublishContentConflict("封面所依据的内容版本已变化,请先核对服务器版本。") + payload = payload.model_copy(update={"expected_updated_at": job.get("updated_at")}) result = generate_publish_cover(payload, job_id=job_id) result["job"] = get_publish_job(job_id) return result @@ -3167,6 +3176,7 @@ def list_publish_history_records( deleted: bool = False, page: int = 1, page_size: int = 50, + job_id: str = "", ) -> dict: normalized_platform = _validate_history_platform(platform) normalized_date = str(date or "").strip() @@ -3184,6 +3194,8 @@ def list_publish_history_records( ) if normalized_date: jobs = [job for job in jobs if job.get("history_date") == normalized_date] + if job_id: + jobs = [job for job in jobs if job.get("id") == job_id] jobs.sort( key=lambda job: ( str(job.get("history_anchor_at") or ""), @@ -3215,6 +3227,7 @@ def list_publish_history_records( "date": normalized_date, "status": str(status or "all").upper(), "deleted": bool(deleted), + "job_id": job_id, }, "timezone": settings.app_timezone, } @@ -3437,10 +3450,16 @@ def update_publish_job_status(job_id: str, status: str, error_message: str = "") return {"status": "ok", "message": "发布任务状态已更新。", "job": get_publish_job(job_id)} +class PublishContentConflict(ValueError): + """A user preview or editor was based on an older persisted version.""" + + def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: job = get_publish_job(job_id) if not job: raise ValueError("发送任务不存在。") + if payload.expected_updated_at is not None and payload.expected_updated_at != job.get("updated_at"): + raise PublishContentConflict("内容已在其他窗口更新;本次修改未覆盖,请保留输入并刷新核对。") if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: raise ValueError("只有草稿、等待或已排期任务可以编辑;失败任务请先创建重试任务。") safe_content = _sanitize_publish_content( @@ -3453,8 +3472,15 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: validate=str(job.get("platform") or "") == "douyin", ) with get_connection() as connection: - account_id = str(job.get("account_id") or "") - if not account_id and str(job.get("publish_mode") or "") == "local_browser": + publish_mode = payload.publish_mode or str(job.get("publish_mode") or "") + if job.get("publish_mode") == "opencli_publish" and payload.publish_mode is not None: + raise ValueError("旧版任务请使用转换入口,不能覆盖发布方式。") + account_id = str(job.get("account_id") or "") if payload.account_id is None else payload.account_id.strip() + if payload.account_id is not None and publish_mode == "local_browser": + account = get_account(account_id) + if not account or account.get("platform") != job.get("platform"): + raise ValueError("真实浏览器发布必须选择当前平台账号。") + if not account_id and publish_mode == "local_browser": account_id = _unique_normal_account_id(connection, str(job.get("platform") or "")) provider_response = _publish_provider_payload( { @@ -3475,7 +3501,7 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: SET title = ?, description = ?, caption = ?, tags = ?, hashtags = ?, visibility = ?, cover_file_path = ?, cover_time_seconds = ?, allow_download = ?, bilibili_tid = ?, bilibili_copyright = ?, bilibili_source = ?, - account_id = ?, provider_response = ?, updated_at = ? + account_id = ?, publish_mode = ?, provider_response = ?, updated_at = ? WHERE id = ? AND status = ? AND updated_at = ? """, ( @@ -3492,6 +3518,7 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: payload.bilibili_copyright, (payload.bilibili_source or "").strip(), account_id or None, + publish_mode, provider_response, _version_iso(), job_id, @@ -3501,7 +3528,7 @@ def update_send_job(job_id: str, payload: PublishSendJobUpdate) -> dict: ) if not cursor.rowcount: connection.rollback() - raise ValueError("发送任务内容或状态已经变化,请刷新后重试。") + raise PublishContentConflict("发送任务内容或状态已经变化,请保留输入并刷新核对。") connection.commit() return {"status": "ok", "message": "发送内容已保存。", "job": get_publish_job(job_id)} @@ -3641,6 +3668,23 @@ def update_publish_job_content(job_id: str, payload: PublishJobContentUpdate) -> return {"status": "ok", "message": "publish content saved", "job": get_publish_job(job_id)} +def preview_send_job_metadata(job_id: str) -> dict: + """Generate a suggestion without updating publish content, schedule or state.""" + job = get_publish_job(job_id) + if not job: + raise ValueError("发送任务不存在。") + if job.get("status") not in {PUBLISH_STATUS_DRAFT, PUBLISH_STATUS_WAITING, PUBLISH_STATUS_SCHEDULED}: + raise ValueError("只有草稿、等待或已排期任务可以生成文案建议。") + item = _get_completed_publish_clip_by_output(job["output_clip_id"]) + if not item: + raise ValueError("找不到这条发送任务对应的切片。") + metadata = generate_publish_metadata(item, use_ai=True, platform=str(job.get("platform") or "douyin")) + if metadata.get("error") or not str(metadata.get("source") or "").startswith("ai:"): + raise ValueError(f"AI 文案建议生成失败:{metadata.get('error') or '没有返回有效文案'}") + return {"status": "ok", "job_id": job_id, "expected_updated_at": job.get("updated_at"), + "metadata": metadata, "message": "建议已生成,接受前不会覆盖待发文案。"} + + def regenerate_send_job_metadata(job_id: str, use_ai: bool = True) -> dict: job = get_publish_job(job_id) if not job: diff --git a/app/services/task_query_service.py b/app/services/task_query_service.py index c0e8bca..5f60b3c 100644 --- a/app/services/task_query_service.py +++ b/app/services/task_query_service.py @@ -15,10 +15,13 @@ from app.services.publish_time import app_zone, parse_datetime from app.services.storage_service import resolve_video_file_path from app.services.subtitle_workflow_service import SUBTITLE_STATUS_LABELS +from app.services.ui_projection_service import STAGES, load_task_rows, task_projections from app.services.task_service import ( OUTPUT_STATUS_LABELS, + _format_datetime, _parse_time_to_seconds, get_default_subtitle_style, + get_platform_label, get_task, list_output_clips, list_tasks, @@ -130,9 +133,35 @@ def _dashboard_weekly_summary(tasks: list[dict], *, now: datetime | None = None) if week_start <= created_at < week_end: weekly_total += 1 + def this_week(value): + if not value: + return False + try: + return week_start <= parse_datetime(value, settings.app_timezone).astimezone(zone) < week_end + except (ValueError, TypeError): + return False + + with get_connection() as connection: + # Output rows are inserted after cutting finishes. Their creation time is + # distinct from both task creation and later subtitle/publishing changes. + outputs = connection.execute(''' + SELECT o.task_id,o.created_at FROM output_clip o JOIN tasks t ON t.id=o.task_id + WHERE o.status='completed' AND o.is_active=1 AND COALESCE(t.is_deleted,0)=0 + ''').fetchall() + deliveries = connection.execute(''' + SELECT p.id,COALESCE(NULLIF(p.published_at,''),NULLIF(p.finished_at,'')) AS completed_at + FROM publish_jobs p JOIN tasks t ON t.id=p.task_id + WHERE p.status='PUBLISHED' AND p.platform='douyin' AND COALESCE(t.is_deleted,0)=0 + ''').fetchall() + produced = [output for output in outputs if this_week(output['created_at'])] + return { "total": weekly_total, "range_label": f"{week_start:%m.%d} - {(week_end - timedelta(days=1)):%m.%d}", + "produced_clips": len(produced), + "production_tasks": len({output['task_id'] for output in produced}), + "published_clips": sum(this_week(job['completed_at']) for job in deliveries), + "published_unknown_time": sum(not job['completed_at'] for job in deliveries), } @@ -180,6 +209,7 @@ def _batch_all_output_clips(task_ids: list[str]) -> dict[str, list[dict]]: clip_candidates.duration_seconds AS clip_duration_seconds, clip_candidates.summary AS clip_summary, clip_candidates.enabled AS clip_enabled, + subtitle_tracks.id AS subtitle_track_id, subtitle_jobs.id AS subtitle_job_id, subtitle_jobs.status AS subtitle_status, subtitle_jobs.revision_id AS subtitle_revision_id, @@ -194,8 +224,13 @@ def _batch_all_output_clips(task_ids: list[str]) -> dict[str, list[dict]]: subtitle_jobs.updated_at AS subtitle_updated_at FROM output_clip LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id - LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 - LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id + LEFT JOIN subtitle_tracks ON subtitle_tracks.output_clip_id = output_clip.id + AND subtitle_tracks.task_id = output_clip.task_id AND subtitle_tracks.is_active = 1 + LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id + AND subtitle_jobs.task_id = output_clip.task_id AND subtitle_jobs.is_active = 1 + AND subtitle_jobs.revision_id = subtitle_tracks.active_revision_id + LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_tracks.active_revision_id + AND subtitle_revisions.track_id = subtitle_tracks.id WHERE output_clip.task_id IN ({placeholders}) AND output_clip.is_active = 1 ORDER BY CASE WHEN output_clip.output_file_name IS NULL OR output_clip.output_file_name = '' THEN 1 ELSE 0 END, @@ -232,7 +267,7 @@ def _batch_all_output_clips(task_ids: list[str]) -> dict[str, list[dict]]: "subtitle_status": subtitle_status, "subtitle_status_label": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), "subtitle_publish_ready": bool( - subtitle_status == "completed" + output.get("subtitle_track_id") and subtitle_status == "completed" and output.get("subtitle_validation_status") == "verified" and output.get("subtitle_revision_status") == "approved" and subtitled_path @@ -252,6 +287,9 @@ def get_dashboard_context(*, now: datetime | None = None) -> dict: """Dashboard 首页统计上下文""" from app.services.production_workbench_service import dashboard tasks = list_tasks() + projections = task_projections([task['id'] for task in tasks]) + for task in tasks: + task['ui'] = projections[task['id']] task_ids = [task["id"] for task in tasks] weekly_summary = _dashboard_weekly_summary(tasks, now=now) completed_oc_map = _batch_completed_output_clip_counts(task_ids) @@ -288,13 +326,86 @@ def get_dashboard_context(*, now: datetime | None = None) -> dict: {"label": "失败任务", "value": failed_count, "note": "需排查", "tone": "red"}, ], "weekly_summary": weekly_summary, + "weekly_stats": [ + dict(label='本周新增制作', value=weekly_summary['total'], note='按任务创建时间统计', tone='blue'), + dict(label='本周生成成片', value=weekly_summary['produced_clips'], + note=f"当前有效成片 · {weekly_summary['production_tasks']} 个任务 · 按成片生成时间", tone='green'), + dict(label='本周实际发送', value=weekly_summary['published_clips'], + note='抖音 · 仅确认成功,按发布完成时间' + (f" · 另 {weekly_summary['published_unknown_time']} 条历史记录缺完成时间" if weekly_summary['published_unknown_time'] else ''), tone='green'), + ], "recent_tasks": tasks[:5], } +def get_tasks_page_context(*, q='', platform='all', stage='all', sort='created_desc', page=1): + """Filter the same read-only stages used by the workbench and task detail.""" + if platform not in {'all', 'douyin', 'bilibili', 'general'}: + raise ValueError('平台筛选无效') + if stage not in {'all', *STAGES}: + raise ValueError('制作阶段筛选无效') + if sort not in {'created_desc', 'created_asc', 'updated_desc'} or page < 1: + raise ValueError('排序或页码无效') + query = q.strip().casefold() + with get_connection() as connection: + # The legacy list formatter probes every task's source and artifacts. + # This page needs only labels/dates plus the authoritative UI projection. + filtered = [] + for task in load_task_rows(connection): + title = task.get('task_name') or '未命名任务' + task_platform = task.get('platform') or 'general' + if query and query not in title.casefold() and query not in task['id'].casefold(): + continue + if platform != 'all' and task_platform != platform: + continue + filtered.append(task) + field = 'updated_at' if sort == 'updated_desc' else 'created_at' + # Preserve the existing displayed-minute/id ordering, including old dates. + filtered.sort(key=lambda task: (_format_datetime(task.get(field)), task['id']), + reverse=sort != 'created_asc') + projections = {} + if stage != 'all' and filtered: + # Stage filtering must inspect every narrowed match before paging. + projections = task_projections(connection=connection, rows=filtered) + filtered = [task for task in filtered if projections[task['id']]['stage'] == stage] + page_size = 25 + total = len(filtered) + pages = max(1, (total + page_size - 1) // page_size) + page = min(page, pages) + page_rows = filtered[(page-1)*page_size:page*page_size] + if stage == 'all' and page_rows: + projections = task_projections(connection=connection, rows=page_rows) + output_counts = {} + if page_rows: + ids = [task['id'] for task in page_rows] + output_counts = {row['task_id']: int(row['cnt']) for row in connection.execute( + f"SELECT task_id,COUNT(*) AS cnt FROM output_clip WHERE is_active=1 " + f"AND task_id IN ({','.join('?' for _ in ids)}) GROUP BY task_id", ids)} + page_tasks = [] + for row in page_rows: + task_platform = row.get('platform') or 'general' + ui = projections[row['id']] + page_tasks.append({ + **row, + 'title': row.get('task_name') or '未命名任务', + 'platform': task_platform, + 'platform_label': get_platform_label(task_platform), + 'created_at': _format_datetime(row.get('created_at')), + 'updated_at': _format_datetime(row.get('updated_at')), + 'created_at_raw': row.get('created_at'), + 'updated_at_raw': row.get('updated_at'), + 'candidate_count': ui['candidates'], + 'output_clip_count': output_counts.get(row['id'], 0), + 'ui': ui, + }) + return dict(tasks=page_tasks, + pagination=dict(page=page, pages=pages, page_size=page_size, total=total), + filters=dict(q=q.strip(), platform=platform, stage=stage, sort=sort), stages=STAGES) + + def get_clips_overview_context() -> dict: """片段总览页统计上下文""" tasks = list_tasks() + projections = task_projections([task['id'] for task in tasks]) clip_counts_map = _batch_clip_candidate_counts([task["id"] for task in tasks]) enriched_tasks = [] for task in tasks: @@ -326,10 +437,11 @@ def get_clips_overview_context() -> dict: enriched_tasks.append( { **task, + "ui": projections[task['id']], "real_clip_count": clip_count, "enabled_clip_count": enabled_count, - "review_stage": review_stage, - "review_tone": review_tone, + "review_stage": projections[task['id']]['stage_label'] if projections[task['id']]['review_required'] else review_stage, + "review_tone": projections[task['id']]['tone'] if projections[task['id']]['review_required'] else review_tone, "can_cut": can_cut, "review_ready": review_ready, } @@ -376,7 +488,7 @@ def _resolve_task_subtitle_stage(output_clips: list[dict]) -> tuple[str, str]: """根据输出切片列表判断字幕整体阶段""" if not output_clips: return "无切片", "amber" - completed = sum(1 for output in output_clips if output.get("subtitle_status") == "completed") + completed = sum(1 for output in output_clips if output.get("subtitle_publish_ready")) if completed == len(output_clips): return "字幕完成", "green" if completed: @@ -387,6 +499,7 @@ def _resolve_task_subtitle_stage(output_clips: list[dict]) -> tuple[str, str]: def get_subtitle_workflow_context() -> dict: """字幕工作台总览页上下文""" tasks = list_tasks() + projections = task_projections([task['id'] for task in tasks]) all_outputs = _batch_all_output_clips([task["id"] for task in tasks]) workflow_tasks = [] total_output_records = 0 @@ -395,18 +508,21 @@ def get_subtitle_workflow_context() -> dict: playable_output_clips = 0 for task in tasks: + task['ui'] = projections[task['id']] + if not task['ui']['subtitle_eligible']: + continue output_clips = all_outputs.get(task["id"], []) for output in output_clips: total_output_records += 1 - if output.get("status") == "completed": + if output.get("status") == "completed" and not output.get('subtitle_publish_ready'): ready_output_clips += 1 - if output.get("subtitle_status") == "completed": + if output.get("subtitle_publish_ready"): completed_subtitles += 1 if output.get("file_exists"): playable_output_clips += 1 - if output_clips: - task_completed_subtitles = sum(1 for output in output_clips if output.get("subtitle_status") == "completed") + if output_clips and task['ui']['subtitle_pending_count']: + task_completed_subtitles = sum(1 for output in output_clips if output.get("subtitle_publish_ready")) if task_completed_subtitles == len(output_clips): subtitle_stage = "字幕完成" subtitle_tone = "green" @@ -433,7 +549,6 @@ def get_subtitle_workflow_context() -> dict: {"label": "待加字幕切片", "value": ready_output_clips, "tone": "blue"}, {"label": "已加字幕成片", "value": completed_subtitles, "tone": "green"}, {"label": "可预览视频", "value": playable_output_clips, "tone": "purple"}, - {"label": "待一键推送", "value": completed_subtitles, "tone": "red"}, ], } @@ -444,11 +559,17 @@ def get_subtitle_task_context(task_id: str) -> dict: if not task: raise ValueError("任务不存在") output_clips = list_output_clips(task_id) + ui = task_projections([task_id])[task_id] + subtitle_stage, subtitle_tone = _resolve_task_subtitle_stage(output_clips) + if ui['subtitle_mode'] == 'original': + subtitle_stage, subtitle_tone = '保留原视频画面', 'green' return { "task": { **task, - "subtitle_stage": _resolve_task_subtitle_stage(output_clips)[0], - "subtitle_tone": _resolve_task_subtitle_stage(output_clips)[1], + "ui": ui, + "subtitle_delivery_mode": ui['subtitle_mode'], + "subtitle_stage": subtitle_stage, + "subtitle_tone": subtitle_tone, }, "output_clips": output_clips, "subtitle_style": get_default_subtitle_style(), @@ -456,12 +577,12 @@ def get_subtitle_task_context(task_id: str) -> dict: {"label": "输出切片", "value": len(output_clips), "tone": "green"}, { "label": "待加字幕", - "value": sum(1 for output in output_clips if output.get("subtitle_status") != "completed"), + "value": ui['subtitle_pending_count'], "tone": "blue", }, { "label": "已加字幕", - "value": sum(1 for output in output_clips if output.get("subtitle_status") == "completed"), + "value": ui['subtitle_done_count'], "tone": "green", }, ], diff --git a/app/services/task_service.py b/app/services/task_service.py index e23545f..75e2318 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -814,6 +814,8 @@ def get_task_live_status(task_id: str) -> dict: task = get_task(task_id, include_video_probe=False) if not task: raise ValueError("任务不存在") + from app.services.ui_projection_service import task_projections + ui = task_projections([task_id])[task_id] status = task["status"] auto_mode = bool(task.get("auto_mode")) @@ -887,6 +889,7 @@ def get_task_live_status(task_id: str) -> dict: return { "task_id": task_id, + "ui": ui, "snapshot_at": _now_iso(), "status": status, "status_label": display_status_label, @@ -1433,6 +1436,7 @@ def list_output_clips(task_id: str) -> list[dict]: clip_candidates.cover_time_seconds AS cover_time_seconds, clip_candidates.summary AS clip_summary, clip_candidates.enabled AS clip_enabled, + subtitle_tracks.id AS subtitle_track_id, subtitle_jobs.id AS subtitle_job_id, subtitle_jobs.status AS subtitle_status, subtitle_jobs.revision_id AS subtitle_revision_id, @@ -1448,7 +1452,11 @@ def list_output_clips(task_id: str) -> list[dict]: FROM output_clip LEFT JOIN clip_candidates ON clip_candidates.id = output_clip.clip_candidate_id LEFT JOIN subtitle_jobs ON subtitle_jobs.output_clip_id = output_clip.id AND subtitle_jobs.is_active = 1 + LEFT JOIN subtitle_tracks ON subtitle_tracks.output_clip_id = output_clip.id + AND subtitle_tracks.task_id = output_clip.task_id AND subtitle_tracks.is_active = 1 + AND subtitle_tracks.active_revision_id = subtitle_jobs.revision_id LEFT JOIN subtitle_revisions ON subtitle_revisions.id = subtitle_jobs.revision_id + AND subtitle_revisions.track_id = subtitle_tracks.id WHERE output_clip.task_id = ? AND output_clip.is_active = 1 ORDER BY CASE WHEN output_clip.output_file_name IS NULL OR output_clip.output_file_name = '' THEN 1 ELSE 0 END, @@ -1485,7 +1493,7 @@ def list_output_clips(task_id: str) -> list[dict]: "subtitle_status": subtitle_status, "subtitle_status_label": SUBTITLE_STATUS_LABELS.get(subtitle_status, subtitle_status), "subtitle_publish_ready": bool( - subtitle_status == "completed" + output.get("subtitle_track_id") and subtitle_status == "completed" and output.get("subtitle_validation_status") == "verified" and output.get("subtitle_revision_status") == "approved" and subtitled_path diff --git a/app/services/ui_projection_service.py b/app/services/ui_projection_service.py new file mode 100644 index 0000000..9a4299d --- /dev/null +++ b/app/services/ui_projection_service.py @@ -0,0 +1,235 @@ +"""Read-only user stages. These projections never grant production permission.""" +from collections import Counter, defaultdict +import json +from pathlib import Path + +from app.db.database import get_connection + + +STAGES = { + "waiting": "待继续制作", "queued": "排队中", "processing": "制作中", + "review": "待审片", "subtitles": "待字幕", "prepare": "待内容准备", + "scheduled": "已排期", "publishing": "发送中", "published": "已发布", "complete": "制作完成", + "error": "制作异常", "publish_review": "发布需处理", +} + + +def _file_exists(value): + try: + return bool(value and Path(value).is_file()) + except (OSError, ValueError): + return False + + +def subtitle_outputs(connection, task_ids=None): + """Only the current track/revision can establish subtitle completion.""" + params = list(task_ids or []) + condition = "" + if task_ids is not None: + if not params: + return {} + condition = f" AND o.task_id IN ({','.join('?' for _ in params)})" + rows = connection.execute(""" + SELECT o.*, st.id AS subtitle_track_id, st.active_revision_id, + sr.status AS subtitle_revision_status, sj.id AS subtitle_job_id, + sj.status AS subtitle_status, sj.validation_status AS subtitle_validation_status, + sj.output_file_path AS subtitled_output_file_path + FROM output_clip o + LEFT JOIN subtitle_tracks st ON st.task_id=o.task_id AND st.output_clip_id=o.id AND st.is_active=1 + LEFT JOIN subtitle_revisions sr ON sr.id=st.active_revision_id AND sr.track_id=st.id + LEFT JOIN subtitle_jobs sj ON sj.task_id=o.task_id AND sj.output_clip_id=o.id + AND sj.revision_id=st.active_revision_id AND sj.is_active=1 + WHERE o.is_active=1""" + condition + " ORDER BY o.id", params).fetchall() + grouped = defaultdict(list) + for record in rows: + output = dict(record) + output["file_exists"] = _file_exists(output.get("output_file_path")) + output["subtitle_publish_ready"] = bool( + output.get("subtitle_track_id") + and output.get("subtitle_revision_status") == "approved" + and output.get("subtitle_status") == "completed" + and output.get("subtitle_validation_status") == "verified" + and _file_exists(output.get("subtitled_output_file_path")) + ) + grouped[output["task_id"]].append(output) + return dict(grouped) + + +def load_task_rows(connection, task_ids=None): + params = list(task_ids or []) + condition = "" + if task_ids is not None: + if not params: + return [] + condition = f" AND t.id IN ({','.join('?' for _ in params)})" + return [dict(row) for row in connection.execute(""" + SELECT t.*, t.task_name AS title, b.id AS batch_item, b.batch_id + FROM tasks t LEFT JOIN material_batch_items b ON b.task_id=t.id + WHERE COALESCE(t.is_deleted,0)=0""" + condition, params)] + + +def _project(connection, rows): + from app.services import production_review_service as reviews + from app.services import cut_evidence_service as cuts + + ids = [row["id"] for row in rows] + if not ids: + return {} + slots = ",".join("?" for _ in ids) + outputs = subtitle_outputs(connection, ids) + candidates = {row["task_id"]: row["count"] for row in connection.execute( + f"SELECT task_id,count(*) count FROM clip_candidates WHERE is_deleted=0 AND task_id IN ({slots}) GROUP BY task_id", ids)} + jobs = defaultdict(list) + for job in connection.execute( + f"SELECT rowid,* FROM workflow_jobs WHERE task_id IN ({slots}) ORDER BY rowid DESC", ids): + jobs[job["task_id"]].append(dict(job)) + published = defaultdict(list) + for job in connection.execute(f""" + SELECT p.* FROM publish_jobs p JOIN output_clip o ON o.id=p.output_clip_id AND o.task_id=p.task_id + WHERE o.is_active=1 AND p.platform='douyin' AND p.task_id IN ({slots}) + ORDER BY p.created_at DESC,p.updated_at DESC,p.id DESC""", ids): + published[job["task_id"]].append(dict(job)) + result = {} + for row in rows: + task_id = row["id"] + status = str(row.get("status") or "").lower() + task_outputs = [o for o in outputs.get(task_id, []) if o["status"] == "completed"] + pending_subtitles = [o for o in task_outputs if not o["subtitle_publish_ready"]] + # External delivery has its own status and must not replace production. + task_jobs = [j for j in jobs[task_id] if j['job_type'] != 'publish'] + active = next((j for j in task_jobs if j["status"] == "running"), None) + active = active or next((j for j in task_jobs if j["status"] == "queued"), None) + latest = task_jobs[0] if task_jobs else {} + try: + config = json.loads(row.get("auto_config_json") or "{}") + config = config if isinstance(config, dict) else {} + except (ValueError, TypeError): + config = {} + modern = bool(row.get("batch_item") or config.get("subtitle_strategy") in {"original", "review"}) + mode = "legacy" + approved = False + blocked = "" + if modern: + try: + mode, _ = reviews.delivery_policy(connection, task_id) + if task_outputs and not active: + receipt = reviews.current_review(connection, task_id) + value = reviews.manifest(connection, task_id) + approved = bool(receipt and receipt["revision"] == value["revision"] + and receipt["manifest_sha256"] == cuts.digest(value)) + except (ValueError, OSError, TypeError, KeyError) as exc: + blocked = str(exc) + mode = "subtitled" if config.get("subtitle_strategy") == "review" else "original" + elif status == "pending_subtitle_review" or any(o.get("subtitle_track_id") for o in task_outputs): + mode = "legacy_subtitled" + # Preserve a successful historical delivery when a newer draft was cancelled. + latest_publications = {} + for job in published[task_id]: + key = (job["output_clip_id"], job["platform"]) + previous = latest_publications.get(key) + if previous is None or (previous["status"] == "CANCELLED" and job["status"] in {"PUBLISHED", "EXPORTED"}): + latest_publications[key] = job + publications = list(latest_publications.values()) + pub_counts = Counter(j["status"] for j in publications) + unprepared = sum(not any(j["output_clip_id"] == o["id"] and j["status"] != "CANCELLED" + for j in publications) for o in task_outputs) + progress = max(0, min(99, int(row.get("progress") or 0))) + production_active = active and active['job_type'] in {'material_import', 'transcript', 'ai_analysis', 'video_cut', 'auto_pipeline'} + raw_processing = status in {'transcribing', 'ai_analyzing', 'cutting', 'audio_extracting', 'preparing_source', 'clip_selecting', 'video_cutting'} + if production_active: + progress = max(0, min(99, int(active.get('progress') or 0))) + elif not raw_processing and (task_outputs or status in {"completed", "ready_to_publish"}): + progress = 100 + stage = "waiting" + tone = "blue" + action = {"label": "继续制作", "url": f"/tasks/{task_id}", "action": "detail"} + category = None + count = 1 + message = "素材已就绪,可继续转写或选片。" + if active: + stage = "processing" if active["status"] == "running" else "queued" + action["label"] = "查看进度" + message = active.get("message") or STAGES[stage] + elif status.startswith("failed") or status in {"completed_with_errors", "failed"} or latest.get("status") == "failed": + stage, category, tone = "error", "errors", "red" + message = latest.get("error_message") or row.get("error_message") or row.get("last_error") or "查看失败步骤,确认后重试。" + action["label"] = "处理异常" + if latest.get("job_type") == "material_import" and row.get("batch_id"): + action["url"] = f"/materials?batch={row['batch_id']}" + elif task_outputs and modern and not approved: + stage, category, count = "review", "clips", len(task_outputs) + message = blocked or "检查当前实际成片,明确确认后继续。" + action = {"label": "检查成片", "url": f"/tasks/{task_id}/clips/review#production-review", "action": "review_outputs"} + elif mode in {"subtitled", "legacy_subtitled"} and pending_subtitles and (approved or not modern): + stage, category, count = "subtitles", "subtitles", len(pending_subtitles) + message = "审核当前字幕版本,并完成渲染验证。" + action = {"label": "处理字幕", "url": f"/subtitles/{task_id}", "action": "subtitle_review"} + elif task_outputs and unprepared and (approved or not modern): + stage, category, count = "prepare", "prepare", unprepared + message = "成片已就绪,请进入内容准备。" + action = {"label": "准备发布", "url": f"/tasks/{task_id}/clips/review#production-review" if modern else f"/publish?task_id={task_id}&tab=content", "action": "review_outputs" if modern else "publish"} + elif pub_counts["PUBLISHING"]: + stage = "publishing" + elif pub_counts["SCHEDULED"]: + stage = "scheduled" + elif pub_counts["DRAFT"] or pub_counts["WAITING"]: + stage = "prepare" + elif pub_counts['PUBLISHED']: + stage, tone = "published", "green" + elif publications or status in {"completed", "ready_to_publish"}: + stage, tone = "complete", "green" + elif candidates.get(task_id) and status == "pending_review": + stage, category, count = "review", "clips", candidates[task_id] + message = "检查候选和选择,再生成实际成片。" + action = {"label": "审片与切片", "url": f"/tasks/{task_id}/clips/review", "action": "review_outputs"} + elif status in {"transcribing", "ai_analyzing", "cutting", "audio_extracting", "preparing_source", "clip_selecting", "video_cutting"}: + stage, action["label"] = "processing", "查看进度" + elif status in {"pending_processing", "pending_ai", "pending_video", "created"}: + category = "start" + if stage in {"scheduled", "publishing", "published", "complete"} or (stage == "prepare" and category is None): + action = {"label": "查看排期" if stage == "scheduled" else "查看发布", "url": f"/publish?task_id={task_id}&tab={'schedule' if stage == 'scheduled' else 'content'}", "action": "publish"} + message = {'scheduled':f"已有 {pub_counts['SCHEDULED']} 条成片排期,可到发送中心查看计划。", + 'publishing':'制作已完成,发送任务正在执行,请在执行记录查看结果。', + 'published':f"已有 {pub_counts['PUBLISHED']} 条成片确认发送成功,其余记录可到发送中心查看。", + 'complete':'制作已完成,发布或导出结果在发送中心单独记录。', + 'prepare':f"已有 {pub_counts['DRAFT'] + pub_counts['WAITING']} 条发布内容待准备或安排发送。"}[stage] + if action['action'] == 'detail' and action['url'].startswith('/tasks/'): + action['url'] += '#analysis' if status == 'pending_ai' else '#production-controls' + subtitle_count = len(pending_subtitles) if mode in {"subtitled", "legacy_subtitled"} and (approved or not modern) else 0 + names = ["素材就绪", "转写与选片", "生成成片", "人工确认"] + completed_steps = 3 if progress == 100 else (2 if candidates.get(task_id) else 1) + if approved or (not modern and progress == 100): + completed_steps = 4 + steps = [{"index": str(i), "name": name, "state": "done" if i <= completed_steps else ("current" if i == completed_steps + 1 else "pending")} + for i, name in enumerate(names, 1)] + if stage == "error": + for step in steps: + if step["state"] == "current": + step["state"] = "warning" + break + label = STAGES[stage] + publish_attention = pub_counts["FAILED"] + pub_counts["NEED_REVIEW"] + if publish_attention and not active and stage not in {'processing', 'queued', 'review', 'subtitles', 'error'}: + stage, label = "publish_review", "发布需处理" + tone = "amber" + job = next(j for j in publications if j["status"] in {"FAILED", "NEED_REVIEW"}) + action = {"label": "复核发布", "url": f"/publish?task_id={task_id}&job_id={job['id']}&tab=history", "action": "publish"} + message = '发布记录需要人工复核,制作进度另行保留。' + ui = dict(stage=stage, stage_label=label, tone=tone, production_progress=progress, + primary_action=action, subtitle_mode=mode, subtitle_pending_count=subtitle_count, + subtitle_done_count=sum(o["subtitle_publish_ready"] for o in task_outputs), + subtitle_eligible=mode in {"subtitled", "legacy_subtitled"} and (approved or not modern), + review_required=modern, review_approved=approved, blocked_reason=blocked, + candidates=candidates.get(task_id, 0), outputs=len(task_outputs), + publish_summary=dict(pub_counts), publish_attention_count=publish_attention, workflow_steps=steps, + category=category, count=count, message=message, + busy=bool(active), running=bool(active and active["status"] == "running")) + result[task_id] = ui + return result + + +def task_projections(task_ids=None, connection=None, rows=None): + if connection is not None: + return _project(connection, rows if rows is not None else load_task_rows(connection, task_ids)) + with get_connection() as connection: + return _project(connection, load_task_rows(connection, task_ids)) diff --git a/app/static/css/content-review.css b/app/static/css/content-review.css index e36af23..c3aa27e 100644 --- a/app/static/css/content-review.css +++ b/app/static/css/content-review.css @@ -397,6 +397,33 @@ margin-top: 18px; } +/* Keep matching actions within each work at ordinary desktop and mobile widths. */ +@media (max-width: 1599px) { + .content-review-works-table { display: block; min-width: 0; } + .content-review-works-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + .content-review-works-table tbody { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } + .content-review-works-table tr { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; gap: 12px; min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 9px; background: #fff; } + .content-review-works-table td { min-width: 0; padding: 0; border: 0; overflow-wrap: anywhere; } + .content-review-works-table td:nth-child(1), .content-review-works-table td:nth-child(2) { min-width: 0; grid-column: 1 / -1; } + .content-review-works-table td:nth-child(1) { order: 0; } + .content-review-works-table td:nth-child(6) { order: 1; grid-column: 1 / -1; padding: 10px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } + .content-review-works-table td:nth-child(2) { order: 2; } + .content-review-works-table td:nth-child(3), .content-review-works-table td:nth-child(4) { order: 3; } + .content-review-works-table td:nth-child(5) { order: 4; grid-column: 1 / -1; } + .content-review-works-table td::before { display: block; margin-bottom: 4px; color: var(--muted); font-size: 11px; } + .content-review-works-table td:nth-child(2)::before { content: '官方指标'; } + .content-review-works-table td:nth-child(3)::before { content: '发布记录'; } + .content-review-works-table td:nth-child(4)::before { content: '候选与审核'; } + .content-review-works-table td:nth-child(5)::before { content: 'AI 与制作方案'; } + .content-review-works-table .content-review-match-tools { min-width: 0; grid-template-columns: minmax(0, 1fr) auto; align-items: center; } + .content-review-match-tools input { width: 100%; min-width: 0; } + .content-review-match-tools strong { overflow-wrap: anywhere; } + .content-review-works-table .content-review-match-tools:has(input) { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 1100px) { + .content-review-works-table tbody { grid-template-columns: minmax(0, 1fr); } +} + @media (max-width: 980px) { .content-review-sync-grid, .content-review-bottom-grid { diff --git a/app/static/css/materials.css b/app/static/css/materials.css index ad324f1..4d0bfb0 100644 --- a/app/static/css/materials.css +++ b/app/static/css/materials.css @@ -1,163 +1,101 @@ -.material-panel { margin-bottom: 20px; min-width: 0; } -.material-panel form { display: grid; gap: 12px; } -.material-panel input[type="text"] { width: 100%; box-sizing: border-box; } -.material-note { color: var(--muted, #687b98); font-size: 14px; line-height: 1.6; } -.material-list { display: grid; gap: 12px; margin: 16px 0; } -.material-item { padding: 16px; border: 1px solid #e3eaf4; border-radius: 14px; min-width: 0; overflow-wrap: anywhere; } -.material-item p { margin: 7px 0; } -.material-check { display: flex; align-items: flex-start; gap: 10px; margin: 16px 0; overflow-wrap: anywhere; } -.material-check input[type="checkbox"] { width: 18px; height: 18px; flex: 0 0 18px; margin-top: 2px; } -.material-item .material-check { margin: 0; } -#material-status { overflow-wrap: anywhere; } -.material-directory-row { display: flex; align-items: center; gap: 12px; min-width: 0; } -.material-directory-row .control { flex: 1; min-width: 0; width: 100%; box-sizing: border-box; } -.material-directory-row button { flex: 0 0 auto; } -.material-directory-dialog { width: min(680px, calc(100vw - 32px)); max-height: 85vh; box-sizing: border-box; border: 1px solid #dce5f2; border-radius: 20px; padding: 24px; color: var(--text, #223047); background: #fff; box-shadow: 0 24px 80px #22304730; } -.material-directory-dialog::backdrop { background: #172b4d55; } -.material-directory-dialog .panel-header { gap: 12px; } +/* Material library and batch setup share the production configuration component. */ +.material-heading { align-items: flex-start; } +.material-view-nav { display: flex; gap: 22px; margin: 0 0 22px; border-bottom: 1px solid var(--line); } +.material-view-nav a { display: flex; gap: 8px; align-items: center; min-height: 42px; border-bottom: 2px solid transparent; color: var(--muted); font-size: 13px; } +.material-view-nav a[aria-current] { color: var(--blue); border-bottom-color: var(--blue); } +#material-inbox-count { padding: 0 6px; border-radius: 5px; background: var(--bg-soft); font-size: 11px; } +.material-panel { min-width: 0; margin-bottom: 22px; } +.material-panel h2 { font-size: 17px; } +.material-note { color: var(--muted); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; } +.material-import-panel { background: var(--surface-muted); } +.material-import-panel h2 { margin-bottom: 16px; } +#material-scan-form { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px 20px; align-items: center; } +#material-scan-form > label:first-child, #material-scan-form > .material-directory-row { grid-column: 1; } +#material-scan-form > label:first-child { font-size: 12px; } +#material-scan-form > #material-scan { grid-column: 2; grid-row: 2; align-self: stretch; } +.material-directory-row { display: flex; gap: 10px; min-width: 0; } +.material-directory-row .control { flex: 1; width: 100%; min-width: 0; } +.material-directory-row button { flex-shrink: 0; } +.material-check { display: flex; align-items: flex-start; gap: 9px; margin: 12px 0; font-size: 12px; } +.material-check input[type="checkbox"] { width: 18px; height: 18px; min-height: 18px; margin: 2px 0 0; padding: 0; flex: 0 0 18px; } +.material-scan-help { grid-column: 1/-1; font-size: 12px; color: var(--muted); } +.material-scan-help summary, .material-source-details summary, .material-batch-details summary { cursor: pointer; } +#material-status, #batch-status { font-size: 12px; overflow-wrap: anywhere; } +#material-preview { border-top: 1px solid var(--line); margin-top: 18px; padding-top: 16px; } +.material-list { display: grid; gap: 7px; margin: 14px 0; } +.material-list .material-item { padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; } +.material-list .material-check { margin: 0; overflow-wrap: anywhere; } +.material-directory-dialog { width: min(680px,calc(100vw - 32px)); max-height: 85dvh; padding: 24px; color: var(--text); border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow-raised); } +.material-directory-dialog::backdrop { background: #20273366; } .material-directory-dialog p { overflow-wrap: anywhere; } -.material-directory-dialog .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .material-directory-actions { margin-top: 16px; } -.material-directory-list { display: grid; gap: 8px; max-height: 36vh; overflow-y: auto; margin: 16px 0; } -.material-directory-list button { text-align: left; white-space: normal; overflow-wrap: anywhere; } -@media (max-width: 600px) { .material-directory-row { flex-direction: column; align-items: stretch; } .material-directory-dialog { padding: 16px; } } -#material-preview[hidden] { display: none; } -#batch-pending[hidden] { display: none; } -#material-batch-form label { min-width: 0; } -#material-batch-form .control { width: 100%; min-width: 0; box-sizing: border-box; } -#material-batch-form .form-row { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 16px; } -@media (max-width: 700px) { #material-batch-form .form-row { grid-template-columns: 1fr; } } -@media (max-width: 600px) { .material-panel .panel-header { align-items: flex-start; flex-wrap: wrap; } } - -/* Material workspace: library, focused setup and compact production history. */ -.material-heading { align-items: center; gap: 20px; } -.material-heading h1 { font-size: clamp(26px, 2.2vw, 36px); letter-spacing: -.04em; } -.material-heading .secondary-button { flex: 0 0 auto; } -.material-panel { border: 1px solid #e1e7ef; box-shadow: 0 8px 26px #253a5710; background: #ffffffed; } -.material-panel h2 { font-size: 20px; margin: 0; letter-spacing: -.025em; } -.material-panel .eyebrow { font-size: 11px; letter-spacing: .12em; color: #718099; margin: 0 0 9px; } -.material-import-panel { padding: 20px 24px; } -.material-import-panel h2 { font-size: 16px; margin-bottom: 14px; } -.material-import-panel #material-scan-form { grid-template-columns: minmax(0, 1fr) auto; gap: 10px 20px; align-items: center; } -#material-scan-form > label:first-child, #material-scan-form > .material-directory-row { grid-column: 1; } -#material-scan-form > label:first-child { font-size: 12px; color: #637189; } -#material-scan-form > .material-check { margin: 0; font-size: 12px; } -#material-scan-form > #material-scan { grid-column: 2; grid-row: 2; min-width: 140px; width: auto; align-self: stretch; } -.material-scan-help { grid-column: 1 / -1; grid-row: 4; font-size: 12px; color: #637189; } -.material-scan-help summary, .material-advanced summary, .material-source-details summary, .material-batch-details summary { cursor: pointer; } -#material-status { font-size: 12px; color: #637189; margin: 8px 0 0; } -.material-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px; align-items: start; } -.material-library-panel { padding: 24px; } -#material-total { font-size: 13px; color: #637189; margin: 8px 0 0; } -.material-library-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 14px 0; margin: 8px 0 0; border-bottom: 1px solid #edf0f5; font-size: 12px; } -.material-library-tools .material-check { margin: 0; align-items: center; font-size: 12px; } -#material-selected-summary { margin-left: auto; color: #2768d7; font-weight: 600; } -.material-text-button { border: 0; background: none; color: #637189; font-size: 12px; padding: 4px; cursor: pointer; } -.material-text-button:disabled { opacity: .45; cursor: default; } -.material-library-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 20px 0; } -.material-source-card { position: relative; padding: 0; border: 1px solid #e4e9f0; border-radius: 16px; background: #fff; overflow: hidden; transition: border-color .16s, box-shadow .16s; } -.material-source-card:hover { border-color: #9cb8df; box-shadow: 0 6px 18px #223b6410; } -.material-source-card.is-selected { border-color: #3877e8; box-shadow: 0 0 0 2px #3877e81c; } -.material-card-face { height: 92px; display: flex; align-items: flex-end; justify-content: space-between; position: relative; padding: 14px; background: linear-gradient(130deg, #e8edf7, #f6f8fd); color: #7486a5; font-size: 10px; font-weight: 700; letter-spacing: .08em; } -.material-source-card:nth-child(3n+2) .material-card-face { background: linear-gradient(130deg, #e9edf4, #f7f5f0); color: #7c8599; } -.material-source-card:nth-child(3n+3) .material-card-face { background: linear-gradient(130deg, #e4f0f0, #f3f7f9); color: #6c8b92; } -.material-film-mark { position: absolute; left: 50%; top: 45%; transform: translate(-50%,-50%); display: grid; place-items: center; width: 42px; height: 32px; padding-left: 2px; border: 1px solid #ffffffdd; border-radius: 9px; background: #ffffff70; color: #8394af; font-size: 13px; } -.material-card-body { padding: 14px; } -.material-card-body h3 { font-size: 13px; line-height: 1.65; margin: 0 0 12px; font-weight: 650; overflow-wrap: anywhere; } -.material-card-meta { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; color: #77859a; font-size: 11px; } -.material-tag { display: inline-flex; align-items: center; border-radius: 6px; padding: 4px 7px; font-size: 10px; font-weight: 600; background: #edf2fc; color: #5d78a4; white-space: nowrap; } -.material-tag.is-verified { background: #edf6f0; color: #387357; } -.material-tag.is-warning { background: #fff2e7; color: #a56325; } -.material-source-details { margin-top: 12px; border-top: 1px solid #f0f2f6; padding-top: 10px; color: #748197; font-size: 11px; } -.material-source-details p { line-height: 1.7; user-select: text; } -.material-item .material-card-select { position: absolute; inset: 0 0 auto; height: 92px; z-index: 1; padding: 10px; display: flex; align-items: flex-start; cursor: pointer; box-sizing: border-box; } +.material-directory-list { display: grid; gap: 7px; max-height: 36vh; overflow-y: auto; margin: 16px 0; } +.material-directory-list button { text-align: left; justify-content: flex-start; white-space: normal; overflow-wrap: anywhere; } +.material-workspace { display: grid; grid-template-columns: minmax(0,1fr) 330px; gap: 22px; align-items: start; } +.material-library-tools { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--line); font-size: 12px; } +.material-library-tools .material-check { margin: 0; align-items: center; } +.material-text-button { background: transparent; border: 0; color: var(--muted); padding: 5px; font-size: 12px; } +.material-text-button:disabled { opacity: .5; } +#material-selected-summary { margin-left: auto; font-size: 12px; color: var(--blue); } +.material-library-grid { display: grid; grid-template-columns: 1fr; gap: 0; margin-top: 6px; } +.material-source-card { display: grid; grid-template-columns: 22px minmax(0,1fr); align-items: start; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--line); min-width: 0; } +.material-source-card:last-child { border-bottom: 0; } +.material-source-card:has(input:checked) { background: var(--blue-soft); } +.material-card-face { display: none; } +.material-card-select { margin: 1px 0 0; padding: 0; } .material-card-select span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } -.material-card-select input[type="checkbox"] { width: 19px; height: 19px; margin: 0; accent-color: #3478f6; cursor: pointer; } -.material-pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid #edf0f5; padding-top: 16px; } +.material-card-body { min-width: 0; } +.material-card-body h3 { margin: 0 0 6px; font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } +.material-card-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: var(--muted); font-size: 11px; } +.material-tag { display: inline-flex; border-radius: 5px; padding: 2px 6px; color: var(--muted); background: var(--bg-soft); font-size: 10px; } +.material-tag.is-verified { color: var(--green); background: var(--green-soft); } +.material-source-details { color: var(--muted); font-size: 11px; margin-top: 8px; } +.material-source-details p { margin: 6px 0 0; overflow-wrap: anywhere; } +.material-card-body > p { color: var(--amber); font-size: 11px; margin-bottom: 0; } +.material-pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 16px; border-top: 1px solid var(--line); } .material-pagination .material-note { font-size: 11px; text-align: center; } -.material-config-panel { padding: 24px; } -.material-config-heading { padding-bottom: 18px; } -.material-config-heading .material-note { margin: 9px 0 0; font-size: 12px; } -#material-batch-form { gap: 18px; } -#material-batch-form > label, #material-batch-form .form-row label, .material-advanced-fields > label, .material-subtitle-setting label { font-size: 12px; font-weight: 600; color: #425169; } -#material-batch-form .control { margin-top: 7px; min-height: 40px; border-radius: 9px; font-size: 12px; box-shadow: none; background-color: #fbfcfe; } -#material-batch-form .form-row { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; } -#material-batch-form .material-check { margin: 0; gap: 10px; font-weight: 400; font-size: 12px; } -#material-batch-form .material-check input { accent-color: #3478f6; } -.material-option { padding: 13px; border: 1px solid #e6ebf3; border-radius: 11px; background: #fafbfd; } -.material-option:has(input:checked) { background: #f0f5ff; border-color: #bed1f2; } -.material-option strong { display: block; font-size: 12px; color: #263c5a; } -.material-option small { display: block; font-size: 11px; line-height: 1.7; color: #758298; margin-top: 5px; } -.material-subtitle-setting .material-note { font-size: 11px; margin: 7px 0 0; } -.material-advanced { border-top: 1px solid #e9edf3; border-bottom: 1px solid #e9edf3; padding: 14px 0; } -.material-advanced summary { font-size: 12px; font-weight: 600; color: #425169; } -.material-advanced summary span { display: inline-block; font-size: 10px; font-weight: 400; color: #8a95a7; margin-left: 6px; } -.material-advanced-fields { display: grid; gap: 14px; margin-top: 18px; } -.material-submit-area { padding-top: 2px; } -#batch-selection-count { color: #2c68c9; font-size: 12px; font-weight: 600; margin: 16px 0 10px; } -#batch-create { width: 100%; min-height: 42px; box-shadow: none; } -#batch-mode-help { font-size: 10px; text-align: center; margin: 9px 0 0; } -#batch-status { font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; } -#batch-status:empty { display: none; } -#batch-pending { margin-top: 16px; padding: 14px; background: #fff8ec; border-radius: 10px; font-size: 12px; } +.material-config-heading { margin-bottom: 20px; } +.material-panel #material-batch-form { display: grid; gap: 18px; } +#material-batch-form .control { width: 100%; } +#material-batch-form .form-row { grid-template-columns: repeat(2,minmax(0,1fr)); } +#material-batch-form .material-check { margin: 0; } +.material-option { padding: 12px; background: var(--surface-muted); border: 1px solid var(--line); border-radius: 8px; } +.material-option strong { display: block; font-size: 12px; } +.material-option small { display: block; color: var(--muted); font-size: 11px; line-height: 1.7; margin-top: 4px; font-weight: 400; } +#batch-selection-count { color: var(--blue); font-size: 12px; margin: 15px 0 10px; } +#batch-create { width: 100%; } +#batch-mode-help { font-size: 11px; margin-bottom: 0; } +#batch-status:empty, #material-inbox-status:empty { display: none; } +#batch-pending { margin-top: 16px; padding: 14px; background: var(--amber-soft); border-radius: 8px; font-size: 12px; } #batch-pending button { margin-top: 8px; width: 100%; } -.material-history-panel { padding: 24px; } -.material-history-panel .panel-header .material-note { font-size: 12px; margin: 8px 0 0; } -.material-batch-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; margin-top: 20px; align-items: start; } -.material-batch-card { border: 1px solid #e3e9f1; border-radius: 14px; padding: 20px; min-width: 0; } -.material-batch-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; } +.material-batch-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; margin-top: 18px; align-items: start; } +.material-batch-card { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: 9px; } +.material-batch-heading { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; } .material-batch-heading h3 { font-size: 14px; margin: 0; } -.material-batch-card .material-note { font-size: 11px; margin: 10px 0; } -.material-batch-card progress { appearance: none; width: 100%; height: 5px; border: 0; border-radius: 4px; background: #eaf0f8; color: #6699e4; display: block; margin: 18px 0 12px; } -.material-batch-card progress::-webkit-progress-bar { background: #eaf0f8; border-radius: 4px; } -.material-batch-card progress::-webkit-progress-value { background: #6699e4; border-radius: 4px; } -.material-batch-card progress::-moz-progress-bar { background: #6699e4; border-radius: 4px; } -.material-batch-details { border-top: 1px solid #eaf0f6; padding-top: 12px; margin-top: 16px; } -.material-batch-details summary { color: #426995; font-size: 12px; } +.material-batch-card progress { display: block; width: 100%; height: 6px; margin: 14px 0; accent-color: var(--blue); } +.material-batch-stages { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; } +.material-batch-stages span { color: var(--muted); background: var(--bg-soft); border-radius: 5px; padding: 3px 7px; font-size: 11px; } +.material-batch-details { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } +.material-batch-details summary { color: var(--blue); font-size: 12px; } .material-batch-items { display: grid; margin-top: 10px; } -.material-batch-task { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: start; gap: 8px 14px; padding: 12px 0; border-top: 1px solid #eff2f7; font-size: 12px; } -.material-batch-task > a, .material-batch-task > span:first-child { overflow-wrap: anywhere; line-height: 1.7; } -.material-task-status { color: #76869b; font-size: 10px; max-width: 120px; overflow-wrap: anywhere; line-height: 1.7; } -.material-task-error { grid-column: 1/-1; color: #a56325; overflow-wrap: anywhere; margin: 0; } -.material-batch-task button { justify-self: start; font-size: 11px; } -.material-empty { grid-column: 1/-1; padding: 40px 20px; text-align: center; border: 1px dashed #d8e1ee; border-radius: 14px; color: #7a889b; font-size: 13px; } -.material-panel :focus-visible { outline: 2px solid #3478f6; outline-offset: 3px; } -@media (min-width: 1600px) { .material-workspace { grid-template-columns: minmax(0,1fr) 400px; } .material-card-face, .material-item .material-card-select { height: 110px; } } -@media (max-width: 1400px) { .material-library-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } .material-workspace { gap: 18px; grid-template-columns: minmax(0,1fr) 330px; } } -@media (max-width: 1050px) { .material-workspace { grid-template-columns: 1fr; } .material-library-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } } -@media (max-width: 700px) { .material-heading { align-items: flex-start; flex-direction: column; } .material-library-grid, .material-batch-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; } .material-panel { padding: 18px; } .material-batch-grid { grid-template-columns: 1fr; } .material-pagination .material-note { display: none; } } -@media (max-width: 600px) { .material-import-panel #material-scan-form { grid-template-columns: minmax(0,1fr); } #material-scan-form > #material-scan { grid-column: 1; grid-row: auto; } .material-scan-help { grid-row: auto; } .material-card-body { padding: 10px; } .material-card-body h3 { font-size: 12px; } .material-card-meta { font-size: 10px; } .material-batch-task { grid-template-columns: minmax(0,1fr); } .material-task-status { max-width: none; } } -@media (prefers-reduced-motion: reduce) { .material-source-card { transition: none; } } - -.material-check input[type="checkbox"] { min-height: 0; padding: 0; } -.material-option small { font-weight: 400; } -.material-card-face { box-sizing: border-box; } - -.material-batch-stages { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.material-batch-stages span { font-size: 11px; color: #617a9b; padding: 5px 8px; border-radius: 6px; background: #f2f5fa; } - -.material-batch-card:only-child { grid-column: 1 / -1; } -@media (min-width: 1100px) { .material-batch-card:only-child .material-batch-items { grid-template-columns: repeat(2,minmax(0,1fr)); column-gap: 28px; } } - -/* One material workspace, with independent library and review views. */ -#material-library-view[hidden], #material-inbox-view[hidden], #material-inbox-retry[hidden] { display: none; } -.material-view-nav { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px; margin: 0 0 24px; width: fit-content; border: 1px solid #e2e9f3; border-radius: 13px; background: #eaf0f8; } -.material-view-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 20px; border-radius: 9px; font-size: 13px; color: #6a7d98; font-weight: 600; text-decoration: none; } -.material-view-nav a[aria-current] { color: #286be0; background: #fff; box-shadow: 0 2px 8px #263e6510; } -#material-inbox-count { padding: 1px 7px; border-radius: 5px; background: #dfe9f8; font-size: 11px; } -.material-view-nav a:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; } -.material-inbox-panel { padding: 24px; } -.material-inbox-panel .panel-header .material-note { margin: 8px 0 0; font-size: 12px; } -#material-inbox-status { color: #607896; font-size: 12px; } -#material-inbox-status:empty { display: none; } -.material-inbox-panel .production-filters { gap: 8px; margin: 20px 0; } -.material-inbox-panel .production-filters a { font-size: 12px; padding: 8px 12px; border-radius: 8px; } -.material-inbox-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 16px; margin: 20px 0; align-items: stretch; } -.material-inbox-grid .production-item { flex-direction: column; align-items: stretch; justify-content: space-between; padding: 20px; border: 1px solid #e3eaf3; border-radius: 14px; background: #fff; min-width: 0; gap: 12px; } -.material-inbox-grid .production-item h3 { margin: 14px 0 10px; font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } -.material-inbox-grid .production-item p { font-size: 12px; line-height: 1.7; margin: 0; } -.material-inbox-grid .production-item > a { align-self: flex-end; font-size: 12px; } -.material-inbox-grid .production-item .status-pill { font-size: 10px; } -.material-inbox-grid .production-empty { grid-column: 1/-1; border: 1px dashed #d9e3f2; border-radius: 14px; font-size: 13px; } -@media (max-width: 1400px) { .material-inbox-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } } -@media (max-width: 700px) { .material-inbox-panel { padding: 18px; } .material-inbox-grid { grid-template-columns: 1fr; } .material-view-nav a { padding: 10px 14px; } } +.material-batch-task { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid var(--line); } +.material-task-title { color: var(--text); font-size: 12px; overflow-wrap: anywhere; } +.material-task-status { color: var(--muted); font-size: 11px; overflow-wrap: anywhere; max-width: 210px; } +.material-batch-task .button-row { gap: 8px; margin-top: 5px; } +.material-batch-task .button-row a { font-size: 11px; color: var(--blue); } +.material-empty, .production-empty { color: var(--muted); text-align: center; padding: 28px 12px; } +#material-inbox-status { color: var(--muted); font-size: 12px; } +.material-inbox-grid { display: grid; margin: 18px 0; } +.material-inbox-grid .production-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px 0; border-top: 1px solid var(--line); } +.material-inbox-grid .production-item > div { min-width: 0; } +.material-inbox-grid .production-item h3 { margin: 8px 0 5px; font-size: 14px; overflow-wrap: anywhere; } +.material-inbox-grid .production-item p { margin: 0; font-size: 12px; color: var(--muted); } +.material-inbox-grid .production-item > a { flex-shrink: 0; } +@media (max-width: 1180px) { .material-workspace { grid-template-columns: minmax(0,1fr) 300px; gap: 16px; } } +@media (max-width: 900px) { .material-workspace { grid-template-columns: 1fr; } .material-batch-grid { grid-template-columns: 1fr; } } +@media (max-width: 600px) { + #material-scan-form { grid-template-columns: 1fr; } #material-scan-form > #material-scan { grid-column: 1; grid-row: auto; } + .material-directory-row { flex-direction: column; } .material-directory-dialog { padding: 18px; } + .material-panel { padding: 18px; } .material-view-nav { gap: 18px; } .material-pagination .material-note { display: none; } + .material-batch-task { grid-template-columns: 1fr; } .material-inbox-grid .production-item { align-items: flex-start; flex-direction: column; } +} diff --git a/app/static/css/production-workbench.css b/app/static/css/production-workbench.css index c2e3a41..bfe3a09 100644 --- a/app/static/css/production-workbench.css +++ b/app/static/css/production-workbench.css @@ -1,6 +1,13 @@ -.production-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:24px} -.production-card{display:flex;flex-direction:column;gap:8px;padding:22px;background:rgba(255,255,255,.8);border:1px solid #e3eaf6;border-radius:18px;color:inherit;text-decoration:none;min-width:0} -.production-card:hover{border-color:#4489f3;background:#fff}.production-card strong{font-size:32px;line-height:1.2}.production-card small,.production-footnote{color:#66758d;font-size:13px;overflow-wrap:anywhere} -.production-filters{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}.production-filters a{padding:9px 14px;border-radius:12px;background:#eef3fa;color:#405674;text-decoration:none}.production-filters .selected{background:#246ae8;color:white} -.production-item{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:22px 0;border-bottom:1px solid #e7edf5}.production-item>div{min-width:0}.production-item h2{font-size:18px;margin:10px 0;overflow-wrap:anywhere}.production-item p{color:#66758d;overflow-wrap:anywhere}.production-item>a{flex-shrink:0}.production-empty{padding:36px 0;text-align:center;color:#66758d} -@media(max-width:1000px){.production-cards{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.production-card{padding:16px}.production-item{align-items:flex-start;flex-direction:column}.production-filters a{font-size:13px;padding:8px 10px}} +/* Compact production summary and inbox filters. */ +.production-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; margin-bottom: 24px; } +.production-card { display: grid; gap: 6px; padding: 18px 20px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 10px; } +.production-card:hover { border-color: #a9bfeb; } +.production-card > span { color: var(--muted); font-size: 12px; } +.production-card strong { color: var(--text); font-size: 25px; line-height: 1.4; } +.production-card small, .production-footnote { color: var(--muted); font-size: 11px; overflow-wrap: anywhere; } +.production-filters { display: flex; flex-wrap: wrap; gap: 7px; margin: 16px 0; } +.production-filters a { display: inline-flex; gap: 5px; padding: 7px 11px; border-radius: 6px; color: var(--muted-strong); background: var(--bg-soft); font-size: 12px; } +.production-filters a.selected { background: var(--blue-soft); color: var(--blue); } +.production-item { min-width: 0; } +.production-empty { padding: 30px; color: var(--muted); } +@media(max-width:900px) { .production-cards { grid-template-columns: repeat(2,minmax(0,1fr)); } } diff --git a/app/static/css/publish-workspace.css b/app/static/css/publish-workspace.css new file mode 100644 index 0000000..9db7c3a --- /dev/null +++ b/app/static/css/publish-workspace.css @@ -0,0 +1,119 @@ +/* Sending workspace: compact rows and one focused content editor. */ +.send-heading { margin-bottom: 14px; padding: 0; } +.send-heading h1 { font-size: 28px; margin: 0 0 4px; } +.send-heading p { margin: 0; font-size: 13px; } +.scheduler-health-card { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 18px; padding: 10px 14px; margin-bottom: 8px; font-size: 12px; } +.scheduler-health-card > div { display: flex; align-items: center; gap: 8px; } +.scheduler-health-card .worker-refresh-button { font-size: 12px; } +.publish-health-details { position: relative; margin-left: auto; } +.publish-health-details summary { cursor: pointer; color: var(--muted); } +.publish-health-detail-body { position: absolute; z-index: 12; right: 0; top: 28px; width: min(460px,calc(100vw - 48px)); padding: 18px; border: 1px solid #dce3ef; border-radius: 12px; background: var(--surface, #fff); box-shadow: 0 14px 40px #182d5120; } +.publish-health-detail-body > small { display: block; line-height: 1.6; } +.publish-health-detail-body .worker-connection-help { margin-top: 10px; padding: 10px; } +.publish-platform-context { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 0 4px; border: 0; background: transparent; box-shadow: none; margin: 0 0 10px; } +.publish-platform-scope { font-size: 12px; color: var(--muted); } +.publish-platform-context .publish-platform-switch { display: flex; } +.publish-platform-context .publish-platform-card { min-height: 0; width: auto; padding: 3px 8px; font-size: 12px; border: 0; box-shadow: none; background: transparent; } +.publish-center-tabs { margin-bottom: 14px; } +.publish-content-header { margin-bottom: 10px; } +.publish-content-header p { margin: 4px 0 0; font-size: 13px; } +.publish-task-group-header { padding: 12px 16px; } +.publish-task-group-identity h3 { font-size: 15px; margin: 0; } +.publish-task-group-identity p { margin: 4px 0; font-size: 12px; } +.publish-platform-context .publish-platform-card { display: inline-flex; gap: 0; grid-template-columns: none; } +@media (max-width: 600px) { + .page-heading.send-heading { flex-direction: row; align-items: center; gap: 8px; } + .send-heading h1 { font-size: 24px; } + .send-heading p { display: none; } + .send-heading .secondary-button { min-height: 36px; padding: 8px 12px; } + .publish-center-panel { padding: 14px; } + .publish-content-header { flex-wrap: wrap; gap: 8px; } + .publish-content-header p { display: none; } + .publish-task-group { padding: 10px; gap: 8px; } + .publish-task-group-header { padding: 0; gap: 10px; } + .publish-task-group-actions { display: flex; flex-wrap: wrap; gap: 8px; } + .publish-task-group-actions [data-task-group-toggle] { width: auto; margin-left: auto; } + .publish-task-group-actions .secondary-button, .publish-task-group-select { min-height: 32px; padding: 6px 10px; } +} +[data-center-panel] [hidden], .publish-dialog [hidden], .publish-selection-bar[hidden], .publish-editor-backdrop[hidden] { display: none !important; } +.publish-content-card { display: grid; grid-template-columns: 24px 96px minmax(0,1fr) auto; align-items: center; padding: 16px; gap: 16px; } +.publish-content-media { width: 96px; height: 64px; min-height: 0; border-radius: 8px; } +.publish-content-media video { display: none; } +.publish-content-media img { width: 100%; height: 100%; object-fit: cover; } +.publish-content-summary { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px 10px; } +.publish-content-summary > strong, .publish-content-summary > small { width: 100%; overflow-wrap: anywhere; } +.publish-content-summary > small { color: var(--muted); } +.publish-content-summary-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.publish-content-card .publish-content-form { display: none; } +.publish-content-dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px,100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; overflow-y: auto; border: 0; background: var(--surface, #fff); color: inherit; box-shadow: -16px 0 56px #182d5120; } +.publish-content-dialog::backdrop { background: #15233b55; } +.publish-content-card.is-editing .publish-content-form { display: flex; flex-direction: column; min-height: 100%; padding: 24px; gap: 18px; } +.publish-editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.publish-editor-heading h2 { font-size: 22px; margin: 0; } +.publish-editor-video { background: #101723; border-radius: 12px; overflow: hidden; } +.publish-editor-video video { width: 100%; max-height: 250px; display: block; } +.publish-editor-backdrop { position: fixed; z-index: 94; inset: 0; background: #15233b55; } +body.has-publish-editor { overflow: hidden; } +.publish-editor-advanced { border-top: 1px solid #e4e8ef; padding-top: 14px; } +.publish-editor-advanced summary { cursor: pointer; margin-bottom: 14px; font-weight: 600; } +.publish-editor-save-state { font-size: 12px; color: var(--muted); } +[data-dirty="true"] [data-editor-save-state], [data-dirty="true"] [data-summary-save-state] { color: #956900; } +.publish-content-form .publish-card-heading-actions .status-pill, .publish-content-form .publish-schedule-badge { display: none; } +.publish-content-maintenance { display: block; padding: 14px 18px; } +.publish-content-maintenance summary { cursor: pointer; font-weight: 600; } +.publish-content-maintenance[open] > div { margin-top: 12px; } +.publish-date-heading { padding: 20px 8px 8px; font-size: 15px; color: #384b63; } +.publish-center-panel, .publish-plan-list, .publish-plan-row > * { min-width: 0; } +.publish-plan-header, .publish-plan-row { grid-template-columns: 28px 150px minmax(0,1fr) 60px 100px 80px minmax(0,1.4fr); } +.publish-plan-row .publish-row-actions { flex-wrap: wrap; } +.publish-plan-row [data-row-account], .publish-plan-row [data-readiness-message] { overflow-wrap: anywhere; } +.publish-plan-row .publish-row-actions > * { max-width: 100%; } +.publish-plan-row.is-task-focus { border-color: #7ca9e4; box-shadow: inset 3px 0 #4b86d3; background: #f2f7ff; } +.publish-task-marker { color: #2764b4; font-size: 11px; white-space: nowrap; background: #e5efff; border-radius: 5px; padding: 2px 6px; } +.publish-task-focus-note { margin: 14px 0; font-size: 13px; } +.publish-task-focus-note .button-row { margin-top: 6px; } +.publish-plan-row [data-publish-now] { background: transparent; color: #3469ba; border: 1px solid #d5deed; box-shadow: none; } +.publish-selection-bar { width: min(1020px,calc(100vw - 300px)); flex-wrap: wrap; } +.publish-selection-bar [data-selection-scope] { flex-basis: 100%; color: var(--muted); max-width: 100%; white-space: normal; } +.publish-dialog { width: min(620px,calc(100vw - 32px)); max-height: calc(100dvh - 48px); overflow-y: auto; border: 1px solid #dce3ef; border-radius: 18px; padding: 24px; background: var(--surface, #fff); color: inherit; box-shadow: 0 24px 90px #13234544; } +.publish-dialog::backdrop { background: #14274366; } +.publish-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; } +.publish-dialog h2 { margin: 0; font-size: 21px; } +.publish-dialog p { overflow-wrap: anywhere; } +.publish-dialog label { display: grid; gap: 8px; margin: 14px 0; } +.publish-dialog .toggle-line { display: flex; } +.publish-dialog video { display: block; max-width: 100%; max-height: 340px; margin-bottom: 16px; } +.publish-confirm-copy { white-space: pre-wrap; line-height: 1.7; } +.publish-preview-dialog { width: min(800px,calc(100vw - 32px)); } +.publish-ai-preview-item { padding: 18px 0; border-top: 1px solid #e1e7f0; } +.publish-ai-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.publish-ai-comparison > div { min-width: 0; padding: 14px; border: 1px solid #e3e8ef; border-radius: 10px; background: #f7f9fc; } +.publish-ai-comparison p { margin: 8px 0; } +.publish-event-timeline article { border-left: 2px solid #d7e1f0; padding: 4px 0 18px 16px; margin-left: 8px; } +.publish-event-timeline time { color: var(--muted); font-size: 12px; display: block; margin-bottom: 6px; } +.publish-event-timeline p { margin: 6px 0; } +[data-center-tab] { min-height: 42px; } +.publish-content-card.is-editing { outline: 2px solid #91b9ff; } +/* Account for the sidebar and panel padding, not only the viewport width. */ +@media (max-width: 1400px) { + .publish-plan-header { display: none; } + .publish-plan-row { grid-template-columns: 28px 132px minmax(0,1fr); align-items: start; } + .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 4; } + .publish-plan-row > :nth-child(2) { grid-column: 2; grid-row: 1; } + .publish-plan-row > :nth-child(3) { grid-column: 3; grid-row: 1; } + .publish-plan-row > :nth-child(4) { grid-column: 2; grid-row: 2; } + .publish-plan-row > :nth-child(5) { grid-column: 3; grid-row: 2; } + .publish-plan-row > :nth-child(6) { grid-column: 2; grid-row: 3; } + .publish-plan-row .publish-row-actions { grid-column: 3; grid-row: 3; justify-content: flex-start; } + .publish-plan-row .publish-send-readiness { text-align: left; } +} +@media (max-width: 1100px) { .publish-selection-bar { width: calc(100vw - 48px); } .publish-content-card { grid-template-columns: 24px 72px minmax(0,1fr); gap: 12px; } .publish-content-media { width: 72px; height: 54px; } .publish-content-summary-actions { grid-column: 3; justify-content: flex-start; } } +@media (max-width: 760px) { + .publish-plan-row { grid-template-columns: 28px minmax(0,1fr); } + .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 7; } + .publish-plan-row > :nth-child(n+2) { grid-column: 2; grid-row: auto; } + .publish-plan-video video { grid-column: 1; grid-row: 1; } + .publish-plan-video .publish-task-marker { grid-column: 2; grid-row: 1; justify-self: start; } + .publish-plan-video strong { grid-column: 1 / -1; grid-row: 2; white-space: normal; overflow: visible; overflow-wrap: anywhere; text-overflow: clip; } +} +@media (max-width: 600px) { .publish-content-card { grid-template-columns: 20px 60px minmax(0,1fr); padding: 12px; gap: 10px; } .publish-content-media { width: 60px; height: 48px; } .publish-content-summary-actions { grid-column: 2 / -1; } .publish-ai-comparison { grid-template-columns: 1fr; } .publish-content-card.is-editing .publish-content-form { padding: 18px; } .publish-dialog { padding: 18px; } .publish-selection-bar { max-height: 45dvh; overflow-y: auto; } } diff --git a/app/static/css/review-workspace.css b/app/static/css/review-workspace.css new file mode 100644 index 0000000..581570f --- /dev/null +++ b/app/static/css/review-workspace.css @@ -0,0 +1,25 @@ +.review-phase-bar {display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:16px 0;padding:12px 16px;background:#fff;border:1px solid var(--border);border-radius:12px} +.review-phase-bar a {padding:7px 10px;color:var(--blue);font-weight:600} +.review-phase-bar [role=status] {margin-left:auto;color:var(--muted);font-size:13px} +.review-workspace {display:grid;grid-template-columns:220px minmax(0,1fr) minmax(300px,370px);gap:16px;align-items:start} +.review-candidates {grid-column:1;grid-row:1;padding:16px;position:sticky;top:88px} +.review-candidates h2 {font-size:16px;margin:0 0 12px}.review-candidates small {font-weight:400;color:var(--muted)} +.review-candidate-list {display:grid;gap:6px;max-height:65vh;overflow:auto} +.review-candidate {display:flex;gap:8px;align-items:start;border:1px solid transparent;border-radius:8px;padding:10px;background:#f6f7f9} +.review-candidate.is-current {border-color:var(--blue);background:#edf4ff} +.review-candidate input {margin-top:4px;flex-shrink:0}.review-candidate button {padding:0;border:0;background:none;color:inherit;text-align:left;min-width:0;flex:1;cursor:pointer} +.review-candidate strong {display:block;font-size:14px;overflow-wrap:anywhere}.review-candidate small {display:block;font-size:12px;margin-top:5px;color:var(--muted)} +.review-player {grid-column:2;grid-row:1;padding:16px;position:sticky;top:88px;min-width:0} +.review-workspace .preview-sticky-core {position:static}.review-player video {width:100%;max-height:45vh;background:#111;border-radius:10px;object-fit:contain} +.review-current-editor {grid-column:3;grid-row:1;display:block;min-width:0}.review-current-editor .clip-card {padding:16px;margin:0;box-shadow:none;display:grid;gap:14px}.review-current-editor .clip-card[hidden] {display:none!important} +.review-current-editor .clip-media-row {display:none}.review-current-editor .clip-quality-card {margin:0;font-size:13px;background:#f7f8fa;padding:10px;border-radius:8px}.review-current-editor .clip-quality-card summary {cursor:pointer;color:var(--muted)} +.review-current-editor .clip-card-header {flex-wrap:wrap;gap:8px}.clip-more {margin-left:auto}.clip-more summary {cursor:pointer;font-size:13px;color:var(--muted)} +.review-current-editor .clip-card-tools {display:block}.review-current-editor .clip-card-tools>span {display:none}.review-current-editor .clip-tool-buttons {display:flex;gap:6px;flex-wrap:wrap} +.review-workspace .clip-bulk-select {display:block;padding:8px 0;margin:0 0 10px}.review-workspace .clip-bulk-select-count {display:block;font-size:12px;margin-top:5px} +.review-player .review-actions {display:flex;flex-wrap:wrap;gap:8px}.review-player .review-action-button {min-width:0;flex:1 1 125px} +.review-help {font-size:13px;color:var(--muted);margin:12px 0}.review-help summary {cursor:pointer}.review-player .output-list {margin-top:16px;padding-top:12px;border-top:1px solid var(--border);font-size:13px}.review-player .output-list summary {cursor:pointer} +.review-view-switch {display:none}.review-workspace~.production-review-panel {margin-top:20px}.task-return {display:inline-block;font-size:13px;color:var(--muted);margin-bottom:8px} +.subtitle-workspace-steps {display:flex;gap:10px;flex-wrap:wrap;padding:12px 16px;margin-bottom:16px;background:#fff;border:1px solid var(--border);border-radius:12px}.subtitle-workspace-steps span {color:var(--muted);font-size:14px} +.subtitle-policy-note {margin-bottom:16px}.subtitle-policy-note p {margin:6px 0 0;color:var(--muted)} +@media(max-width:1279px) {.review-workspace {grid-template-columns:190px minmax(0,1fr)}.review-current-editor {grid-column:2;grid-row:2}.review-player {position:static}.review-candidates {grid-row:1 / span 2}.review-view-switch {display:flex;gap:8px;margin-bottom:12px}.review-view-switch button {background:#f2f4f7;border:1px solid var(--border);border-radius:8px;padding:7px 12px;cursor:pointer}.review-view-switch button[aria-pressed=true] {background:#edf4ff;border-color:var(--blue);color:var(--blue)}.review-workspace[data-view=player] .review-current-editor {display:none}.review-workspace[data-view=editor] .review-player .video-preview,.review-workspace[data-view=editor] .review-player .review-help,.review-workspace[data-view=editor] .review-player .output-list {display:none}} +@media(max-width:767px) {.review-workspace {display:flex;flex-direction:column}.review-workspace>*,.review-current-editor {width:100%}.review-candidates {position:static}.review-candidate-list {max-height:180px}.review-player {padding:12px}.review-player video {max-height:38vh}.review-phase-bar {gap:4px;padding:8px}.review-phase-bar a {font-size:13px;padding:5px}.review-phase-bar [role=status] {margin:0;width:100%}.review-current-editor .form-row {grid-template-columns:1fr 1fr}.review-current-editor input {min-width:0}.subtitle-editor-trackbar {flex-wrap:wrap}.subtitle-editor-state {flex-wrap:wrap}} diff --git a/app/static/css/studio.css b/app/static/css/studio.css new file mode 100644 index 0000000..6432e93 --- /dev/null +++ b/app/static/css/studio.css @@ -0,0 +1,229 @@ +/* Shared foundations. Business styles live in their own workspaces. */ +:root { + --bg: #f6f7f9; --bg-soft: #eef1f5; --surface: #fff; --surface-solid: #fff; + --surface-muted: #f8f9fb; --surface-deep: #edf3ff; --text: #202733; + --muted: #697386; --muted-strong: #455063; --text-secondary: #697386; + --line: #e5e8ee; --line-strong: #ced4df; --blue: #2468df; --blue-deep: #1959c8; + --blue-soft: #edf3ff; --green: #217a54; --green-soft: #edf8f1; + --amber: #966013; --amber-soft: #fff7e8; --red: #bb3d46; --red-soft: #fff0f1; + --purple: #7353b6; --purple-soft: #f4effb; --shadow: 0 2px 8px #20273305; + --shadow-soft: 0 1px 4px #20273305; --shadow-raised: 0 18px 60px #20273326; + --border: var(--line); --glass-border: #fff; --radius: 12px; --sidebar-width: 220px; +} +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; min-height: 100vh; color: var(--text); background: var(--bg); font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } +a { color: inherit; text-decoration: none; } +a:hover { color: var(--blue); } +button:disabled { cursor: not-allowed; } +[hidden] { display: none !important; } +:focus-visible { outline: 3px solid #2468df70; outline-offset: 3px; } +input:focus-visible, select:focus-visible, textarea:focus-visible { outline-offset: 1px; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +.skip-link { position: fixed; z-index: 1000; left: 16px; top: 12px; padding: 10px 18px; background: #fff; border-radius: 8px; transform: translateY(-160%); } +.skip-link:focus { transform: none; } +.app-shell { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100vh; } +.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 28px 16px 20px; background: #fbfcfd; border-right: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px; margin-bottom: 40px; } +.brand-mark { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 9px; overflow: hidden; } +.brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; } +.brand strong, .brand small { display: block; } +.brand strong { font-size: 16px; font-weight: 700; } +.brand small { font-size: 11px; color: var(--muted); margin-top: 2px; } +.side-nav { display: grid; gap: 6px; } +.side-nav a, .studio-settings-link { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 13px; border-radius: 8px; color: var(--muted-strong); font-weight: 500; } +.side-nav a:hover, .studio-settings-link:hover { background: var(--bg-soft); } +.side-nav a.active, .studio-settings-link.active { background: var(--blue-soft); color: var(--blue); font-weight: 650; } +.nav-icon { display: inline-flex; align-items: center; justify-content: center; width: 20px; font-size: 20px; line-height: 1; flex: 0 0 20px; } +.sidebar-footer { display: grid; gap: 18px; margin-top: auto; padding-top: 30px; } +.studio-local-label { display: flex; align-items: center; gap: 7px; padding-left: 13px; font-size: 11px; color: var(--muted); } +.studio-local-label i { width: 6px; height: 6px; background: var(--green); border-radius: 50%; } +.main-panel { min-width: 0; padding: 0 36px 48px; } +.topbar { position: relative; z-index: 40; display: flex; align-items: center; gap: 20px; min-height: 88px; margin-bottom: 22px; border-bottom: 1px solid var(--line); } +.studio-search { position: relative; display: flex; align-items: center; gap: 8px; width: min(500px, 100%); margin-right: auto; } +.studio-search input { width: 100%; min-width: 0; height: 40px; padding: 8px 37px 8px 37px; border: 1px solid var(--line); border-radius: 9px; background: #fff; color: var(--text); } +.studio-search-icon { position: absolute; left: 12px; color: var(--muted); font-size: 23px; line-height: 1; pointer-events: none; } +.studio-search-submit { position: absolute; right: 8px; width: 25px; height: 25px; color: var(--muted); border: 0; background: var(--bg); border-radius: 4px; } +.studio-search-results { position: absolute; top: calc(100% + 8px); left: 0; right: 0; max-height: 420px; overflow-y: auto; padding: 8px; background: #fff; border: 1px solid var(--line); box-shadow: var(--shadow-raised); border-radius: 10px; } +.studio-search-status { margin: 6px 8px; color: var(--muted); font-size: 12px; } +.studio-search-result { display: flex; flex-direction: column; gap: 3px; padding: 10px; border-radius: 7px; } +.studio-search-result:hover, .studio-search-result:focus { background: var(--blue-soft); } +.studio-search-result strong { font-size: 13px; overflow-wrap: anywhere; } +.studio-search-result small { color: var(--muted); } +.studio-search-all { display: block; padding: 10px; border-top: 1px solid var(--line); color: var(--blue); } +.studio-menu-button.icon-button, .studio-nav-backdrop { display: none; } +.studio-create-button { white-space: nowrap; gap: 7px; flex-shrink: 0; } +.studio-page-content { max-width: 1600px; margin: 0 auto; min-width: 0; } +.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin: 0 0 24px; } +.page-heading h1 { margin: 0; color: var(--text); font-size: 28px; font-weight: 700; line-height: 1.4; } +.page-heading p { margin: 8px 0 0; color: var(--muted); font-size: 13px; } +.page-heading p:not(.eyebrow), .detail-locator { max-width: 900px; overflow-wrap: anywhere; } +.page-heading.task-detail-heading { display: block; } +.task-detail-heading h1 { overflow-wrap: anywhere; } +.task-header-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; margin-top: 16px; } +.task-header-actions > .primary-button, .task-header-actions summary { flex-shrink: 0; white-space: nowrap; } +[data-auto-pipeline-monitor] summary { cursor: pointer; font-weight: 600; } +.eyebrow { margin: 0 0 7px; color: var(--muted); font-size: 11px; letter-spacing: .06em; font-weight: 600; } +.primary-button, .secondary-button, .pagination button, .icon-button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 8px 15px; border: 1px solid transparent; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .15s, border-color .15s; } +.primary-button { color: #fff; background: var(--blue); } +.primary-button:hover { color: #fff; background: var(--blue-deep); } +.secondary-button, .pagination button, .icon-button { color: var(--muted-strong); background: #fff; border-color: var(--line-strong); } +.secondary-button:hover, .pagination button:hover:not(:disabled), .icon-button:hover:not(:disabled) { color: var(--blue); border-color: #99b5e7; background: var(--blue-soft); } +.primary-button:disabled, .secondary-button:disabled, .pagination button:disabled, .icon-button:disabled { opacity: .48; cursor: not-allowed; } +.compact-button { min-height: 32px; padding: 5px 11px; } +.icon-button { width: 38px; padding: 6px; } +a.text-button, button.text-button, a.link-button, button.link-button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 7px 10px; border: 0; border-radius: 7px; color: var(--blue); background: transparent; box-shadow: none; font-size: 13px; font-weight: 500; line-height: 1.5; text-decoration: none; vertical-align: middle; cursor: pointer; transition: color .15s, background .15s; } +a.text-button:hover, button.text-button:hover:not(:disabled), a.link-button:hover, button.link-button:hover:not(:disabled) { color: var(--blue-deep); background: var(--blue-soft); } +a.text-button.danger, button.text-button.danger, a.link-button.danger, button.link-button.danger { color: var(--red); } +a.text-button.danger:hover, button.text-button.danger:hover:not(:disabled), a.link-button.danger:hover, button.link-button.danger:hover:not(:disabled) { color: var(--red); background: var(--red-soft); } +button.text-button:disabled, button.link-button:disabled, a.text-button[aria-disabled="true"], a.link-button[aria-disabled="true"] { color: var(--muted); background: transparent; opacity: .48; cursor: not-allowed; } +a.text-button[aria-disabled="true"], a.link-button[aria-disabled="true"] { pointer-events: none; } +.panel, .stat-card, .modal-panel, .transcript-drawer { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } +.panel { padding: 24px; } +.panel h2 { margin: 0; font-size: 18px; } +.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.panel-header h2, .panel-header h3 { color: var(--text); } +.panel-header p:not(.eyebrow) { color: var(--muted); } +.stat-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; } +.stat-card { position: relative; display: grid; gap: 6px; padding: 18px; min-width: 0; } +.stat-icon { display: none; } +.stat-card span { color: var(--muted); font-size: 12px; } +.stat-card strong { font-size: 27px; line-height: 1.35; color: var(--text); } +.stat-card small { color: var(--muted); font-size: 12px; font-weight: 400; overflow-wrap: anywhere; } +.platform-badge, .status-pill { display: inline-flex; align-items: center; justify-content: center; min-height: 25px; padding: 3px 9px; border-radius: 6px; font-size: 11px; font-weight: 500; white-space: nowrap; } +.platform-badge { background: var(--bg-soft); color: var(--muted-strong); } +.platform-badge.douyin { background: #fff0f4; color: #a64061; } +.platform-badge.bilibili { background: #edf7ff; color: #277caa; } +.status-pill { color: var(--blue); background: var(--blue-soft); } +.status-pill.tone-green { color: var(--green); background: var(--green-soft); } +.status-pill.tone-amber { color: var(--amber); background: var(--amber-soft); } +.status-pill.tone-red { color: var(--red); background: var(--red-soft); } +.status-pill.tone-purple { color: var(--purple); background: var(--purple-soft); } +.control, label input, label select, label textarea, fieldset input { width: 100%; min-width: 0; min-height: 40px; padding: 9px 12px; color: var(--text); border: 1px solid var(--line-strong); border-radius: 7px; background: #fff; } +.control { width: auto; } +label input[type="checkbox"], label input[type="radio"], fieldset input[type="checkbox"], fieldset input[type="radio"] { width: 18px; min-height: 18px; padding: 0; flex: 0 0 18px; accent-color: var(--blue); } +.control:focus, label input:focus, label select:focus, label textarea:focus, fieldset input:focus { border-color: var(--blue); } +input::placeholder, textarea::placeholder { color: #929bad; } +.page-alert, .inline-alert { padding: 13px 16px; border: 1px solid #d5e3fc; border-radius: 8px; background: var(--blue-soft); overflow-wrap: anywhere; } +.empty-state { padding: 36px 24px; text-align: center; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; } +.table-wrap { margin-top: 16px; overflow-x: auto; border: 1px solid var(--line); border-radius: 9px; background: #fff; } +table { width: 100%; min-width: 720px; border-collapse: collapse; } +thead { background: var(--surface-muted); } +th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; } +th { color: var(--muted); font-size: 12px; font-weight: 500; } +td { color: var(--text); font-size: 13px; } +tbody tr:hover { background: #f9fbfd; } +tbody tr:last-child td { border-bottom: 0; } +.progress, .large-progress { overflow: hidden; height: 7px; border-radius: 99px; background: var(--bg-soft); } +.large-progress { height: 9px; margin: 24px 0 12px; } +.progress span, .large-progress span { display: block; height: 100%; border-radius: inherit; background: var(--blue); } +.studio-tabs { display: flex; align-items: center; gap: 24px; margin: 0 0 24px; border-bottom: 1px solid var(--line); } +.studio-tabs button, .studio-tabs a { display: inline-flex; min-height: 42px; padding: 8px 1px; border: 0; border-bottom: 2px solid transparent; color: var(--muted); background: transparent; font-size: 14px; white-space: nowrap; } +.studio-tabs [aria-selected="true"], .studio-tabs [aria-current="page"] { color: var(--blue); border-bottom-color: var(--blue); font-weight: 600; } +.studio-tab-panel { min-width: 0; } +.studio-tab-panel > .panel, .studio-tab-panel > section { margin-bottom: 20px; } +.production-config, .production-advanced-fields { display: grid; gap: 18px; } +.production-config label { display: grid; align-content: start; gap: 7px; font-size: 13px; } +.production-config label.checkbox-row { display: flex; } +.production-advanced { padding: 14px 0; border-top: 1px solid var(--line); } +.production-advanced summary { cursor: pointer; color: var(--muted-strong); font-weight: 600; } +.production-advanced summary span { display: inline; margin-left: 8px; color: var(--muted); font-size: 12px; font-weight: 400; } +.production-advanced-fields { margin-top: 18px; } +.production-config .form-hint { display: block; color: var(--muted); font-size: 12px; font-weight: 400; } +.production-config .form-hint a { color: var(--blue); } +.studio-create-layout { max-width: 1040px; display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; } +.studio-create-layout .form-panel { gap: 22px; } +.studio-source-tabs { margin-bottom: 22px; } +.studio-plan-grid { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; } +.studio-plan-list { display: grid; align-content: start; gap: 8px; } +.studio-plan-choice { width: 100%; padding: 13px; text-align: left; color: var(--text); background: #fff; border: 1px solid var(--line); border-radius: 8px; } +.studio-plan-choice[aria-pressed="true"] { color: var(--blue); background: var(--blue-soft); border-color: #aec8f4; } +.studio-plan-choice small { display: block; color: var(--muted); margin-top: 4px; } +.studio-plan-editor { display: grid; gap: 18px; } +.studio-plan-editor label { display: grid; gap: 7px; } +.studio-plan-editor textarea { min-height: 360px; resize: vertical; } +.studio-plan-context { color: var(--muted); font-size: 12px; margin: 0; } +.studio-confirm-dialog { width: min(460px, calc(100vw - 32px)); padding: 26px; color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow-raised); } +.studio-confirm-dialog::backdrop { background: #20273366; } +.studio-confirm-dialog h2 { font-size: 18px; margin: 0 0 14px; } +.studio-confirm-dialog p { color: var(--muted); white-space: pre-line; } +.studio-confirm-dialog .button-row { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; } +.danger-button { background: var(--red); } +.challenger-evidence { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } +.challenger-text { min-height: 18rem; } +.challenger-history { display: grid; gap: 12px; } +.studio-attention-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.studio-attention-list { display: grid; margin-top: 16px; } +.studio-attention-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 20px; padding: 17px 0; border-top: 1px solid var(--line); } +.studio-attention-item h3 { margin: 0 0 5px; font-size: 14px; overflow-wrap: anywhere; } +.studio-attention-item p { margin: 0; font-size: 12px; color: var(--muted); } +.studio-attention-empty { margin-top: 20px; padding: 25px; background: var(--surface-muted); border-radius: 8px; } +.studio-attention-empty strong { display: block; font-size: 16px; } +.studio-attention-empty p { color: var(--muted); margin-bottom: 0; } +.studio-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); gap: 24px; align-items: start; margin-bottom: 24px; } +.studio-weekly-stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 20px; margin-top: 24px; } +.studio-weekly-stats article { min-width: 0; } +.studio-weekly-stats span { display: block; color: var(--muted); font-size: 12px; } +.studio-weekly-stats strong { display: block; font-size: 28px; margin: 4px 0; } +.studio-weekly-stats small { color: var(--muted); font-size: 11px; } +.studio-recent-list { display: grid; margin-top: 12px; } +.studio-recent-row { display: grid; grid-template-columns: minmax(0,1fr) 120px 100px auto; gap: 20px; align-items: center; padding: 16px 0; border-top: 1px solid var(--line); } +.studio-recent-row h3 { margin: 0; font-size: 14px; overflow-wrap: anywhere; } +.studio-recent-row small { color: var(--muted); font-size: 11px; } +.studio-recent-row .progress { margin: 5px 0; } +.task-filter-form { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 20px; } +.task-filter-form label { display: grid; gap: 5px; font-size: 12px; color: var(--muted); } +.task-filter-form label:first-child { flex: 1; min-width: 190px; } +.task-filter-form .control { width: 100%; } +.task-row-meta { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; } +.task-row-actions { display: flex; align-items: center; gap: 10px; } +.task-more-menu { position: relative; } +.task-more-menu > summary { cursor: pointer; color: var(--muted); font-size: 12px; list-style: none; } +.task-more-actions { position: absolute; right: 0; top: 100%; z-index: 20; min-width: 160px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fff; box-shadow: var(--shadow-raised); } +.task-more-actions a, .task-more-actions button { display: block; width: 100%; padding: 8px; text-align: left; font-size: 12px; border: 0; background: #fff; } +.task-more-actions a:hover, .task-more-actions button:hover { background: var(--surface-muted); } +.task-detail-fold { min-width: 0; margin: 20px 0; border: 1px solid var(--line); border-radius: 10px; background: #fff; } +.task-detail-fold > summary { padding: 16px 20px; cursor: pointer; font-weight: 600; } +.task-detail-fold > .panel { border: 0; border-radius: 0; box-shadow: none; } +.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; } +.pagination a { display: inline-flex; align-items: center; min-height: 36px; padding: 7px 14px; border: 1px solid var(--line-strong); border-radius: 7px; white-space: nowrap; background: #fff; font-size: 12px; } +.pagination span { width: auto; min-width: 30px; padding: 4px 7px; white-space: nowrap; } +@media (max-width: 1180px) { :root { --sidebar-width: 196px; } .main-panel { padding-right: 24px; padding-left: 24px; } .studio-dashboard-grid { gap: 18px; } } +@media (max-width: 900px) { + .app-shell { display: block; } .main-panel { padding: 0 20px 32px; } + .sidebar { position: fixed; z-index: 90; left: 0; top: 0; bottom: 0; height: 100dvh; width: 240px; transform: translateX(-100%); visibility: hidden; transition: transform .2s, visibility .2s; } + body.studio-navigation-open .sidebar { transform: none; visibility: visible; } + .studio-nav-backdrop:not([hidden]) { display: block; position: fixed; inset: 0; z-index: 80; padding: 0; border: 0; background: #20273366; } + .studio-menu-button.icon-button { display: inline-flex; flex: 0 0 38px; } + .topbar { min-height: 76px; gap: 12px; margin-bottom: 20px; } + .studio-dashboard-grid, .studio-create-layout { grid-template-columns: 1fr; } + .studio-create-layout > .stacked-side { display: none; } + .stat-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } +} +@media (max-width: 600px) { + a.text-button, button.text-button, a.link-button, button.link-button { min-height: 44px; padding: 10px 12px; } + .main-panel { padding: 0 14px 28px; } .topbar { flex-wrap: wrap; padding: 14px 0; gap: 12px; } + .studio-search { order: 3; width: 100%; } .studio-create-button { margin-left: auto; } + .page-heading { flex-direction: column; gap: 12px; margin-bottom: 20px; } .page-heading h1 { font-size: 24px; } + .panel { padding: 18px; } .studio-tabs { gap: 18px; overflow-x: auto; } .stat-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .studio-plan-grid { grid-template-columns: 1fr; } .studio-plan-list { grid-template-columns: repeat(2,minmax(0,1fr)); } + .studio-recent-row { grid-template-columns: minmax(0,1fr) auto; gap: 10px; } .studio-recent-row > .studio-recent-progress { display: none; } + .studio-recent-row .status-pill { justify-self: start; } .studio-attention-item { gap: 12px; } + .production-task-table { min-width: 0; } + .production-task-table thead { display: none; } + .production-task-table, .production-task-table tbody, .production-task-table tr { display: block; } + .production-task-table tr { padding: 14px 0; border-bottom: 1px solid var(--line); } + .production-task-table td { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 6px 14px; border: 0; overflow-wrap: anywhere; } + .production-task-table td::before { content: attr(data-label); color: var(--muted); font-size: 11px; flex: 0 0 60px; } + .production-task-table td:first-child { display: block; } + .production-task-table td:first-child::before { display: none; } + .production-task-table td > div { min-width: 0; } + .task-filter-form > label { flex: 1 1 42%; min-width: 0; } + .task-filter-form > label:first-child { flex: 1 1 100%; } + .task-row-actions { flex-wrap: wrap; } + .production-advanced summary span { display: block; margin: 4px 0 0 16px; } +} +@media (pointer: coarse) { a.text-button, button.text-button, a.link-button, button.link-button { min-height: 44px; } } +@media (prefers-reduced-motion: reduce) { .sidebar { transition: none; } } diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 26791b1..55b2ed9 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1,261 +1,4 @@ -:root { - --bg: #f4f7fb; - --bg-soft: #eef3fa; - --surface: rgba(255, 255, 255, 0.9); - --surface-solid: #ffffff; - --surface-muted: #f8fafd; - --text: #1b2433; - --muted: #6f7a8a; - --line: rgba(27, 36, 51, 0.09); - --line-strong: rgba(27, 36, 51, 0.14); - --blue: #256fff; - --blue-soft: #eaf2ff; - --green: #22a06b; - --green-soft: #e8f8f0; - --amber: #d98622; - --amber-soft: #fff4df; - --red: #e15252; - --red-soft: #fff0f0; - --purple: #7566d9; - --purple-soft: #f0edff; - --shadow: 0 16px 42px rgba(24, 39, 75, 0.08); - --shadow-soft: 0 8px 24px rgba(24, 39, 75, 0.06); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - overflow-x: hidden; - background: - radial-gradient(circle at 12% 0%, rgba(37, 111, 255, 0.08), transparent 32%), - linear-gradient(180deg, #fbfdff 0%, var(--bg) 42%, #f7f9fc 100%); - color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; - letter-spacing: 0; -} - -a { - color: inherit; - text-decoration: none; -} - -button, -input, -select, -textarea { - font: inherit; -} - -button { - border: 0; -} - -.app-shell { - display: grid; - grid-template-columns: 236px minmax(0, 1fr); - min-height: 100vh; -} - -.sidebar { - position: sticky; - top: 0; - height: 100vh; - padding: 20px 18px; - border-right: 1px solid var(--line); - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(20px); -} - -.brand { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 28px; - padding: 8px; -} - -.brand-mark { - display: block; - width: 38px; - height: 38px; - flex: 0 0 38px; - border-radius: 9px; - overflow: hidden; - filter: drop-shadow(0 10px 18px rgba(37, 111, 255, 0.18)); -} - -.brand-mark img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.brand strong, -.brand small { - display: block; -} - -.brand small { - margin-top: 2px; - color: var(--muted); - font-size: 12px; -} - -.side-nav { - display: grid; - gap: 6px; -} - -.side-nav a { - display: flex; - align-items: center; - gap: 10px; - min-height: 38px; - padding: 0 12px; - border-radius: 8px; - color: #455066; - font-size: 14px; -} - -.side-nav a.active, -.side-nav a:hover { - background: var(--blue-soft); - color: var(--blue); -} - -.nav-dot { - width: 7px; - height: 7px; - border-radius: 50%; - border: 1px solid currentColor; -} - -.side-nav a.active .nav-dot { - background: currentColor; -} - -.sidebar-note { - position: absolute; - right: 18px; - bottom: 20px; - left: 18px; - display: grid; - gap: 6px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface-solid); - box-shadow: var(--shadow-soft); -} - -.sidebar-note span { - color: var(--muted); - font-size: 12px; - line-height: 1.65; -} - -.main-panel { - min-width: 0; - padding: 18px 28px 44px; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-bottom: 22px; -} - -.search-box { - display: flex; - align-items: center; - gap: 10px; - width: min(540px, 100%); - min-height: 40px; - padding: 0 13px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(255, 255, 255, 0.74); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); -} - -.search-mark { - width: 14px; - height: 14px; - border: 2px solid #a2adbf; - border-radius: 50%; - position: relative; -} - -.search-mark::after { - position: absolute; - right: -5px; - bottom: -4px; - width: 6px; - height: 2px; - border-radius: 999px; - background: #a2adbf; - content: ""; - transform: rotate(45deg); -} - -.search-box input { - width: 100%; - border: 0; - outline: 0; - background: transparent; - color: var(--text); -} - -.topbar-actions { - display: flex; - align-items: center; - gap: 12px; -} - -.topbar-actions .primary-button, -.topbar-actions .secondary-button { - min-width: 92px; - white-space: nowrap; -} - -.notice-dot { - width: 34px; - height: 34px; - border: 1px solid var(--line); - border-radius: 50%; - background: var(--surface-solid); - position: relative; -} - -.notice-dot::after { - position: absolute; - top: 8px; - right: 9px; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--red); - content: ""; -} - -.user-chip { - min-height: 34px; - padding: 7px 12px; - border: 1px solid var(--line); - border-radius: 999px; - background: var(--surface-solid); - font-size: 13px; -} - -.page-heading, -.panel-header, +/* Business workspace styles. Shared foundations are in studio.css. */ .toolbar, .clip-card-header, .button-row { @@ -264,152 +7,21 @@ button { justify-content: space-between; gap: 14px; } - -.page-heading { - margin-bottom: 20px; -} - -.page-heading h1 { - margin: 0; - font-size: 28px; - line-height: 1.2; - letter-spacing: 0; -} - -.page-heading p { - margin: 8px 0 0; - color: var(--muted); -} - -.eyebrow { - margin: 0 0 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; -} - -.primary-button, -.secondary-button, -.pagination button, -.icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 38px; - padding: 0 15px; - border-radius: 8px; - cursor: pointer; - transition: 0.18s ease; -} - -.primary-button:disabled, -.secondary-button:disabled, -.pagination button:disabled, -.icon-button:disabled { - cursor: not-allowed; - opacity: 0.58; - transform: none; -} - -.primary-button { - background: linear-gradient(135deg, #4b8dff, var(--blue)); - color: #fff; - box-shadow: 0 12px 20px rgba(37, 111, 255, 0.2); -} - -.primary-button:hover { - transform: translateY(-1px); -} - -.secondary-button, -.pagination button, -.icon-button { - border: 1px solid var(--line); - background: var(--surface-solid); - color: var(--text); -} - -.icon-button { - width: 38px; - padding: 0; -} - -.panel, -.stat-card { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface); - box-shadow: var(--shadow); -} - -.panel { - padding: 20px; -} - -.panel h2 { - margin: 0; - font-size: 18px; - letter-spacing: 0; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(5, minmax(130px, 1fr)); - gap: 14px; - margin-bottom: 16px; -} - -.stat-card { - position: relative; - display: grid; - gap: 7px; - min-height: 122px; - padding: 16px; - overflow: hidden; -} - -.stat-icon { - width: 28px; - height: 28px; - border-radius: 8px; - background: currentColor; - opacity: 0.16; -} - -.stat-card span { - color: var(--muted); - font-size: 13px; -} - -.stat-card strong { - font-size: 29px; - line-height: 1; -} - -.stat-card small { - color: var(--green); - font-weight: 700; -} - .tone-blue { color: var(--blue); } .tone-green { color: var(--green); } .tone-amber { color: var(--amber); } .tone-red { color: var(--red); } .tone-purple { color: var(--purple); } - .tone-amber small, .tone-red small { color: inherit; } - .content-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(260px, 0.75fr); gap: 16px; margin-bottom: 16px; } - .workflow-line { position: relative; display: grid; @@ -417,7 +29,6 @@ button { gap: 8px; margin-top: 18px; } - .workflow-line::before { position: absolute; top: 22px; @@ -427,7 +38,6 @@ button { background: #d9dee8; content: ""; } - .workflow-step { position: relative; display: grid; @@ -437,7 +47,6 @@ button { font-size: 12px; text-align: center; } - .workflow-step span, .timeline-list span { z-index: 1; @@ -451,25 +60,21 @@ button { color: #8b96a8; font-weight: 800; } - .active-2 .workflow-step:nth-child(-n + 2) span, .vertical-flow li:nth-child(-n + 3) span { border-color: var(--blue); background: var(--blue); color: #fff; } - .active-2 .workflow-step:nth-child(-n + 2) strong, .vertical-flow li:nth-child(-n + 3) strong { color: var(--text); } - .insight-panel .compact-list li { display: flex; align-items: center; gap: 10px; } - .insight-panel strong { display: grid; width: 34px; @@ -479,7 +84,6 @@ button { background: var(--blue-soft); color: var(--blue); } - .compact-list { display: grid; gap: 12px; @@ -489,126 +93,26 @@ button { line-height: 1.65; list-style: none; } - .compact-list li, .path-text { overflow-wrap: anywhere; word-break: break-word; } - -.table-wrap { - overflow-x: auto; - margin-top: 16px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface-solid); -} - -table { - width: 100%; - min-width: 820px; - border-collapse: collapse; -} - -thead { - background: #f2f5fa; -} - -th, -td { - padding: 13px 14px; - border-bottom: 1px solid var(--line); - text-align: left; - vertical-align: middle; -} - -th { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -tbody tr:hover { - background: #fbfcff; -} - -tbody tr:last-child td { - border-bottom: 0; -} - .empty-table-cell { padding: 34px 18px; color: var(--muted); text-align: center; } - -.platform-badge, -.status-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 26px; - border-radius: 999px; - font-size: 12px; - font-weight: 700; - white-space: nowrap; -} - -.platform-badge { - min-width: 38px; - padding: 0 10px; - background: var(--surface-muted); - color: #4b5568; -} - -.platform-badge.douyin { - background: #fff0f4; - color: #d62f61; -} - -.platform-badge.bilibili { - background: #e9f7ff; - color: #1684c7; -} - .platform-badge.general { background: #f0f2f6; color: #626c7d; } - -.status-pill { - padding: 0 10px; - background: var(--blue-soft); - color: var(--blue); -} - -.status-pill.tone-green { - background: var(--green-soft); - color: var(--green); -} - -.status-pill.tone-amber { - background: var(--amber-soft); - color: var(--amber); -} - -.status-pill.tone-red { - background: var(--red-soft); - color: var(--red); -} - -.status-pill.tone-purple { - background: var(--purple-soft); - color: var(--purple); -} - .task-status-cell { display: grid; gap: 6px; justify-items: start; max-width: 280px; } - .task-status-cell small { display: -webkit-box; overflow: hidden; @@ -618,134 +122,48 @@ tbody tr:last-child td { -webkit-box-orient: vertical; -webkit-line-clamp: 2; } - -.progress, -.large-progress { - overflow: hidden; - height: 8px; - border-radius: 999px; - background: #e6ebf3; -} - -.large-progress { - height: 12px; - margin: 28px 0 12px; -} - -.progress span, -.large-progress span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--blue), #6fa4ff); -} - .progress-number { color: var(--blue); font-size: 30px; } - .live-progress-summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } - .live-progress-summary span { color: var(--muted); font-size: 12px; font-weight: 700; } - .toolbar { flex-wrap: wrap; justify-content: flex-start; } - .compact-toolbar { justify-content: flex-end; } - -.control, -label input, -label select, -label textarea, -fieldset input { - width: 100%; - min-height: 40px; - padding: 10px 12px; - border: 1px solid var(--line-strong); - border-radius: 8px; - background: #fff; - color: var(--text); - outline: 0; -} - -.control { - width: auto; - min-width: 174px; -} - .action-links { display: flex; gap: 12px; } - .action-links a, -td a, -.link-button { - color: var(--blue); - font-weight: 700; -} - -.link-button { - min-height: 0; - padding: 0; - background: transparent; - cursor: pointer; -} - -.link-button.danger { - color: var(--red); -} - -.pagination { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - margin-top: 16px; -} - -.pagination span { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border-radius: 8px; - background: var(--blue-soft); +td a { color: var(--blue); font-weight: 700; } - -.pagination small { - color: var(--muted); -} - .split-layout, .review-layout { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.8fr); gap: 16px; } - .form-panel, .clip-card { display: grid; gap: 17px; } - label, fieldset { display: grid; @@ -754,22 +172,18 @@ fieldset { padding: 0; border: 0; } - label span, legend, dt { font-weight: 700; } - dt { color: var(--muted); font-size: 12px; } - dd { margin: 0; } - .segmented, .source-grid, .form-row { @@ -777,11 +191,9 @@ dd { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } - #long-live-settings[hidden] { display: none; } - .segmented label, .toggle-line, .inline-switch { @@ -794,14 +206,12 @@ dd { border-radius: 8px; background: #fff; } - .segmented input, .toggle-line input, .inline-switch input { width: auto; min-height: 0; } - .checkbox-row { display: flex; align-items: center; @@ -812,13 +222,11 @@ dd { border-radius: 8px; background: #fff; } - .checkbox-row input { width: auto; min-height: 0; padding: 0; } - .drop-zone, .nas-zone { display: grid; @@ -832,17 +240,14 @@ dd { color: var(--muted); text-align: center; } - .drop-zone strong, .nas-zone strong { color: var(--text); } - .nas-zone { border-style: solid; background: #f9fafc; } - .upload-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -856,28 +261,23 @@ dd { linear-gradient(135deg, rgba(37, 111, 255, 0.08), transparent 48%), #f8fbff; } - .upload-card div { display: grid; gap: 7px; } - .upload-card strong { font-size: 16px; } - .upload-card span { color: var(--muted); line-height: 1.6; } - .segmented-control { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } - .segmented-control label { display: inline-flex; flex-direction: row; @@ -889,7 +289,6 @@ dd { background: #fff; cursor: pointer; } - .file-browser-list { display: grid; max-height: 300px; @@ -898,7 +297,6 @@ dd { border-radius: 10px; background: #fff; } - .file-browser-item { width: 100%; padding: 11px 13px; @@ -909,13 +307,11 @@ dd { text-align: left; cursor: pointer; } - .file-browser-item:hover, .file-browser-item.is-selected { background: rgba(37, 111, 255, 0.08); color: var(--blue); } - .file-upload-button { display: inline-flex; grid-auto-flow: column; @@ -928,16 +324,14 @@ dd { padding: 0 16px; border: 1px solid rgba(37, 111, 255, 0.2); border-radius: 8px; - background: linear-gradient(135deg, #4b8dff, var(--blue)); + background: var(--blue); color: #fff; cursor: pointer; - box-shadow: 0 12px 20px rgba(37, 111, 255, 0.2); + box-shadow: none; } - .file-upload-button span { color: #fff; } - .file-upload-icon { position: relative; width: 18px; @@ -945,21 +339,18 @@ dd { border: 2px solid currentColor; border-radius: 6px; } - .file-upload-icon::before, .file-upload-icon::after { position: absolute; content: ""; background: currentColor; } - .file-upload-icon::before { top: 4px; left: 7px; width: 2px; height: 8px; } - .file-upload-icon::after { top: 4px; left: 5px; @@ -968,7 +359,6 @@ dd { transform: rotate(45deg); box-shadow: -3px 3px 0 currentColor; } - fieldset input.visually-hidden-file { position: absolute; width: 1px; @@ -979,7 +369,6 @@ fieldset input.visually-hidden-file { overflow: hidden; clip: rect(0, 0, 0, 0); } - .unit-input { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -989,56 +378,37 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: #fff; } - .unit-input input { border: 0; border-radius: 0; } - .unit-input small { padding: 0 12px; color: var(--muted); font-weight: 700; } - .align-end { justify-content: flex-end; } - .form-hint { margin: 0; color: var(--blue); font-size: 13px; } - -.page-alert { - margin: 0 0 16px; - padding: 13px 15px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--blue-soft); - color: var(--blue); - font-weight: 700; - line-height: 1.55; -} - .page-alert[data-tone="success"] { background: var(--green-soft); color: var(--green); } - .page-alert[data-tone="error"] { background: var(--red-soft); color: var(--red); } - .path-picker { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; } - .browser-panel { display: grid; gap: 12px; @@ -1047,19 +417,16 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: var(--surface-muted); } - .browser-results { display: grid; gap: 10px; } - .browser-list { display: grid; gap: 8px; max-height: 260px; overflow: auto; } - .browser-item { min-height: 36px; padding: 8px 10px; @@ -1070,26 +437,21 @@ fieldset input.visually-hidden-file { cursor: pointer; text-align: left; } - .browser-item:hover { border-color: rgba(37, 111, 255, 0.4); color: var(--blue); } - .path-text { overflow-wrap: anywhere; } - .stacked-side { display: grid; align-content: start; gap: 16px; } - .soft-tip { background: linear-gradient(180deg, #ffffff, #f5f9ff); } - .timeline-list { display: grid; gap: 10px; @@ -1097,7 +459,6 @@ fieldset input.visually-hidden-file { padding: 0; list-style: none; } - .timeline-list li { display: grid; grid-template-columns: 32px minmax(0, 1fr); @@ -1107,26 +468,15 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: var(--surface-muted); } - -.detail-locator { - display: flex; - flex-wrap: wrap; - gap: 8px 14px; - margin: 4px 0 0; - color: var(--muted); -} - .detail-locator span { display: inline-flex; align-items: center; min-height: 20px; overflow-wrap: anywhere; } - .status-overview-card { - min-height: 210px; + min-height: 0; } - .live-current-operation { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1138,7 +488,6 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: rgba(37, 111, 255, 0.06); } - .live-current-operation div { display: flex; flex-wrap: wrap; @@ -1146,17 +495,14 @@ fieldset input.visually-hidden-file { align-items: baseline; min-width: 0; } - .live-current-operation div span { color: var(--muted); font-size: 12px; } - .live-current-operation > strong { color: var(--blue); font-size: 18px; } - .live-current-operation p { grid-column: 1 / -1; margin: 0; @@ -1164,38 +510,32 @@ fieldset input.visually-hidden-file { font-size: 12px; line-height: 1.55; } - .live-current-operation[data-status="attention"], .live-current-operation[data-status="failed"] { border-color: rgba(230, 159, 0, 0.24); background: rgba(230, 159, 0, 0.08); } - .live-current-operation[data-status="attention"] > strong, .live-current-operation[data-status="failed"] > strong { color: var(--amber); } - [data-task-live-status-pill][data-status="running"] { background: var(--blue-soft); color: var(--blue); } - [data-task-live-status-pill][data-status="failed"] { background: var(--amber-soft); color: var(--amber); } - .metro-timeline { position: relative; display: grid; - grid-template-columns: repeat(7, minmax(44px, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 22px 0 0; padding: 0; list-style: none; } - .metro-timeline::before { position: absolute; top: 15px; @@ -1206,7 +546,6 @@ fieldset input.visually-hidden-file { background: #dfe6f0; content: ""; } - .metro-step { position: relative; z-index: 1; @@ -1217,7 +556,6 @@ fieldset input.visually-hidden-file { color: var(--muted); text-align: center; } - .metro-step span { display: grid; width: 32px; @@ -1230,45 +568,37 @@ fieldset input.visually-hidden-file { font-size: 13px; font-weight: 800; } - .metro-step strong { - max-width: 64px; + max-width: none; color: inherit; font-size: 12px; line-height: 1.35; } - .metro-step.done span { border-color: var(--green); background: var(--green); color: #fff; } - .metro-step.done strong { color: var(--green); } - .metro-step.current span { border-color: var(--blue); background: var(--blue); color: #fff; box-shadow: 0 8px 18px rgba(37, 111, 255, 0.22); } - .metro-step.current strong { color: var(--blue); } - .metro-step.warning span { border-color: var(--amber); background: var(--amber); color: #fff; } - .metro-step.warning strong { color: var(--amber); } - .metro-legend { display: flex; flex-wrap: wrap; @@ -1277,85 +607,72 @@ fieldset input.visually-hidden-file { color: var(--muted); font-size: 12px; } - .metro-legend span { display: inline-flex; align-items: center; gap: 6px; } - .metro-legend i { width: 9px; height: 9px; border-radius: 50%; } - .metro-legend .done { background: var(--green); } .metro-legend .current { background: var(--blue); } .metro-legend .warning { background: var(--amber); } - .live-status-note { margin: 12px 0 0; color: var(--muted); font-size: 12px; } - .live-status-note[data-state="active"] { color: var(--blue); } - .live-status-note[data-state="completed"] { color: var(--green); } - .live-status-note[data-state="error"] { color: var(--amber); } - [data-live-task-actions] [hidden] { display: none; } - -.detail-grid { +.detail-grid, +.task-detail-layout { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(340px, 0.72fr); - gap: 16px; -} - -.detail-grid > aside { - grid-column: 3; - grid-row: 1 / span 3; - align-self: start; -} - -.process-panel, -.transcript-panel { - grid-column: span 2; + grid-template-columns: minmax(0, 1fr); + gap: 18px; } - -.summary-card { - min-height: 210px; +.task-detail-layout > *, +.detail-grid > * { min-width: 0; } +.detail-grid > aside { align-self: start; } +.task-detail-support { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 18px; } - +.task-detail-support > .panel { min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; } +.task-detail-support .task-detail-fold { margin: 0; overflow: hidden; } +.task-detail-support .task-detail-fold[open] { padding-bottom: 20px; } +.task-detail-support .task-detail-fold > :not(summary) { margin-left: 20px; margin-right: 20px; } +.summary-card { min-height: 0; } .meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0 0; } - .meta-grid div, .clip-copy div { display: grid; gap: 5px; } - .transcript-box { display: grid; gap: 10px; margin-top: 16px; } - .transcript-progress { display: grid; gap: 10px; @@ -1365,18 +682,15 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: rgba(255, 255, 255, 0.72); } - .transcript-progress[data-status="failed"], .transcript-progress[data-status="stale"] { border-color: rgba(220, 38, 38, 0.24); background: rgba(254, 242, 242, 0.78); } - .transcript-progress[data-status="completed"] { border-color: rgba(22, 163, 74, 0.22); background: rgba(240, 253, 244, 0.78); } - .ai-analysis-progress { display: grid; gap: 10px; @@ -1386,43 +700,35 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: rgba(247, 251, 255, 0.84); } - .ai-analysis-progress[hidden] { display: none; } - .ai-analysis-progress[data-status="running"] .progress span { background: linear-gradient(90deg, var(--blue), #22a06b); } - .ai-analysis-progress[data-status="failed"] { border-color: rgba(220, 38, 38, 0.24); background: rgba(254, 242, 242, 0.78); } - .ai-analysis-progress[data-status="completed"] { border-color: rgba(22, 163, 74, 0.22); background: rgba(240, 253, 244, 0.78); } - .transcript-progress-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } - .transcript-progress-heading strong { color: var(--text); } - .transcript-progress-heading span, .transcript-progress p { margin: 0; color: var(--muted); font-weight: 700; } - .transcript-box p { margin: 0; padding: 13px 14px; @@ -1430,7 +736,6 @@ fieldset input.visually-hidden-file { background: var(--surface-muted); line-height: 1.7; } - .transcript-box time { display: inline-flex; min-width: 72px; @@ -1438,12 +743,10 @@ fieldset input.visually-hidden-file { color: var(--blue); font-weight: 800; } - .transcript-full-panel { padding: 0; overflow: hidden; } - .transcript-full-text { max-height: calc(100vh - 220px); margin: 0; @@ -1457,18 +760,15 @@ fieldset input.visually-hidden-file { white-space: pre-wrap; overflow-wrap: anywhere; } - .review-layout { grid-template-columns: minmax(0, 2fr) minmax(380px, 1fr); align-items: start; gap: 18px; } - .clip-list { display: grid; gap: 10px; } - .clip-bulk-select { display: flex; align-items: center; @@ -1484,7 +784,6 @@ fieldset input.visually-hidden-file { box-shadow: 0 8px 24px rgba(31, 77, 143, 0.08); backdrop-filter: blur(14px); } - .clip-bulk-select-control { min-height: 32px; padding: 4px 8px; @@ -1493,44 +792,36 @@ fieldset input.visually-hidden-file { cursor: pointer; font-weight: 750; } - .clip-bulk-select-count { color: var(--muted); font-size: 13px; font-weight: 700; } - .clip-card { gap: 11px; padding: 12px; } - .clip-card label { gap: 4px; } - .clip-card label span { font-size: 12px; } - .clip-card input, .clip-card textarea { min-height: 34px; padding: 7px 10px; } - .clip-index { color: var(--muted); font-weight: 800; } - .clip-media-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; align-items: center; } - .clip-media-row p { display: -webkit-box; margin: 4px 0 0; @@ -1540,7 +831,6 @@ fieldset input.visually-hidden-file { -webkit-box-orient: vertical; -webkit-line-clamp: 2; } - .thumb-placeholder { display: grid; width: 92px; @@ -1557,13 +847,11 @@ fieldset input.visually-hidden-file { font-weight: 700; font-family: inherit; } - .thumb-placeholder span { display: inline-flex; align-items: center; gap: 6px; } - .thumb-placeholder span::before { width: 0; height: 0; @@ -1572,19 +860,16 @@ fieldset input.visually-hidden-file { border-left: 9px solid currentColor; content: ""; } - .thumb-placeholder:hover { color: var(--blue); box-shadow: inset 0 0 0 1px rgba(37, 111, 255, 0.22); } - .clip-timeline { position: relative; height: 12px; border-radius: 999px; background: #e7edf6; } - .clip-timeline span { position: absolute; top: 0; @@ -1592,14 +877,12 @@ fieldset input.visually-hidden-file { border-radius: inherit; background: var(--blue); } - .clip-copy { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0; } - .clip-copy div { min-height: 74px; padding: 10px; @@ -1607,25 +890,21 @@ fieldset input.visually-hidden-file { border-radius: 8px; background: rgba(255, 255, 255, 0.64); } - .clip-copy dt { margin-bottom: 6px; color: var(--text); font-weight: 800; } - .clip-copy dd { margin: 0; color: var(--muted); line-height: 1.5; } - .clip-card textarea { height: 74px; min-height: 74px; resize: vertical; } - .clip-card-tools { display: flex; align-items: center; @@ -1633,26 +912,22 @@ fieldset input.visually-hidden-file { gap: 12px; padding: 2px 0 0; } - .clip-tool-buttons { display: flex; flex-wrap: wrap; gap: 8px; } - .clip-card-tools span { display: none; color: var(--muted); font-size: 13px; line-height: 1.5; } - .clip-details { border: 1px solid var(--line); border-radius: 8px; background: rgba(255, 255, 255, 0.58); } - .clip-details summary { min-height: 34px; padding: 8px 10px; @@ -1661,22 +936,18 @@ fieldset input.visually-hidden-file { font-size: 13px; font-weight: 800; } - .clip-details .clip-copy { padding: 0 10px 10px; } - .compact-delete-button { min-height: 28px; padding: 0 4px; font-size: 13px; } - .clip-card.is-removing { opacity: 0.55; pointer-events: none; } - .quality-tier { display: inline-flex; align-items: center; @@ -1686,17 +957,14 @@ fieldset input.visually-hidden-file { font-size: 13px; font-weight: 800; } - .quality-tier-a { color: #11643f; background: rgba(52, 199, 89, 0.13); } - .quality-tier-b { color: #875700; background: rgba(255, 159, 10, 0.14); } - .clip-quality-card { display: grid; gap: 10px; @@ -1706,19 +974,16 @@ fieldset input.visually-hidden-file { border-radius: 16px; background: rgba(0, 122, 255, 0.045); } - .clip-quality-card p { margin: 0; color: var(--muted-text); line-height: 1.65; } - .clip-quality-scores { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } - .clip-quality-scores span { display: grid; gap: 2px; @@ -1728,16 +993,13 @@ fieldset input.visually-hidden-file { color: var(--muted-text); font-size: 12px; } - .clip-quality-scores strong { color: var(--text-color); font-size: 20px; } - .quality-warning { color: #9a5a00 !important; } - .clip-feedback { display: grid; gap: 8px; @@ -1745,74 +1007,43 @@ fieldset input.visually-hidden-file { padding-top: 14px; border-top: 1px solid var(--border-color); } - .clip-feedback > span { color: var(--muted-text); font-size: 13px; } - .clip-feedback > div { display: flex; flex-wrap: wrap; gap: 8px; } - .clip-feedback button.is-active { border-color: rgba(0, 122, 255, 0.55); color: var(--primary-color); background: rgba(0, 122, 255, 0.1); } - -.compact-button { - min-height: 34px; - padding: 0 12px; - font-size: 13px; - font-weight: 700; -} - .review-actions { margin-top: 18px; } - -button:disabled { - cursor: not-allowed; - opacity: 0.58; - transform: none; -} - -.empty-state { - display: grid; - gap: 10px; - min-height: 220px; - align-content: center; - justify-items: start; -} - .empty-state p { margin: 0; color: var(--muted); line-height: 1.7; } - .empty-actions { justify-content: flex-start; margin-top: 8px; } - .review-stat-grid { grid-template-columns: repeat(5, minmax(150px, 1fr)); } - .clip-review-stat-grid { grid-template-columns: repeat(3, minmax(180px, 1fr)); } - .review-board { display: grid; gap: 12px; margin-top: 16px; } - .review-task-row { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(320px, 0.9fr) auto; @@ -1823,32 +1054,27 @@ button:disabled { border-radius: 8px; background: var(--surface-muted); } - .review-task-main { display: grid; justify-items: start; gap: 7px; } - .review-task-main h3 { margin: 0; font-size: 17px; } - .review-task-main p, .review-error { margin: 0; color: var(--muted); line-height: 1.55; } - .review-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; } - .review-metrics div { display: grid; gap: 4px; @@ -1858,72 +1084,61 @@ button:disabled { border-radius: 8px; background: #fff; } - .review-metrics dd { color: var(--text); font-weight: 800; } - .review-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } - .review-error { grid-column: 1 / -1; padding-top: 2px; } - .system-grid { - grid-template-columns: repeat(5, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); } - .system-grid .stat-card { min-width: 0; } - +#studio-settings-runtime .content-grid > .panel { min-width: 0; } +#studio-settings-runtime .stat-card strong, #studio-settings-runtime .compact-list { overflow-wrap: anywhere; } .system-grid small { overflow-wrap: anywhere; line-height: 1.5; } - .ai-config-panel { margin-top: 22px; } - .ai-interface-form { overflow: visible; } - .ai-interface-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: stretch; } - .ai-interface-section { align-content: start; } - .ai-interface-section input, .ai-interface-section select { min-width: 0; } - .inline-actions { position: static; padding-top: 2px; } @media (max-width: 1180px) { - .ai-interface-grid { +.ai-interface-grid { grid-template-columns: 1fr; } } - .modal-backdrop { position: fixed; inset: 0; @@ -1934,24 +1149,9 @@ button:disabled { background: rgba(17, 25, 39, 0.34); backdrop-filter: blur(14px); } - .modal-backdrop[hidden] { display: none !important; } - -.modal-panel { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: min(920px, 100%); - max-height: min(780px, calc(100vh - 48px)); - overflow: hidden; - padding: 22px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgba(255, 255, 255, 0.96); - box-shadow: 0 24px 70px rgba(17, 25, 39, 0.22); -} - .modal-panel > .panel-header { position: sticky; top: 0; @@ -1959,7 +1159,6 @@ button:disabled { padding-bottom: 14px; background: rgba(255, 255, 255, 0.96); } - .settings-form { display: grid; gap: 16px; @@ -1967,7 +1166,6 @@ button:disabled { overflow: auto; padding-right: 4px; } - .settings-section { display: grid; gap: 12px; @@ -1976,37 +1174,30 @@ button:disabled { border-radius: 8px; background: var(--surface-muted); } - .settings-section h3 { margin: 0; font-size: 16px; } - .ai-prompt-form { overflow: visible; } - .ai-analysis-actions { align-items: flex-end; } - .ai-analysis-actions .ai-provider-control { flex: 1 1 260px; min-width: 0; max-width: 400px; } - .ai-analysis-controls { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; } - .ai-analysis-controls label { max-width: 260px; } - .prompt-preset-tabs { display: flex; align-items: center; @@ -2014,7 +1205,6 @@ button:disabled { padding: 4px 4px 0; border-bottom: 1px solid var(--line); } - .prompt-preset-tab { position: relative; display: inline-flex; @@ -2031,24 +1221,20 @@ button:disabled { cursor: pointer; transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease; } - .prompt-preset-tab input { position: absolute; opacity: 0; pointer-events: none; } - .prompt-preset-tab.active { border-color: rgba(37, 111, 255, 0.36); background: linear-gradient(180deg, #ffffff, #f7fbff); color: var(--blue); box-shadow: 0 -6px 18px rgba(37, 111, 255, 0.08); } - .prompt-preset-panels { padding-top: 16px; } - .prompt-preset-card { display: grid; gap: 14px; @@ -2059,11 +1245,9 @@ button:disabled { background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 251, 255, 0.72)); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); } - .prompt-preset-card[hidden] { display: none; } - .prompt-preset-card textarea { min-height: 360px; resize: vertical; @@ -2071,7 +1255,6 @@ button:disabled { font-size: 13px; line-height: 1.65; } - .ai-analysis-result-card { display: grid; gap: 14px; @@ -2082,29 +1265,24 @@ button:disabled { background: linear-gradient(180deg, #ffffff, #f7fbff); box-shadow: 0 14px 32px rgba(37, 111, 255, 0.08); } - .ai-analysis-result-card[hidden] { display: none; } - .ai-analysis-result-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } - .ai-analysis-result-header h3 { margin: 0; font-size: 18px; } - .ai-analysis-result-message { margin: 0; color: var(--muted); line-height: 1.7; } - .ai-analysis-result-meta { display: flex; align-items: center; @@ -2114,16 +1292,13 @@ button:disabled { border-radius: 8px; background: rgba(37, 111, 255, 0.08); } - .ai-analysis-result-meta span { color: var(--muted); } - .ai-analysis-result-list { display: grid; gap: 8px; } - .ai-analysis-result-item { display: grid; gap: 4px; @@ -2132,12 +1307,10 @@ button:disabled { border-radius: 8px; background: rgba(255, 255, 255, 0.78); } - .ai-analysis-result-item span { color: var(--muted); font-size: 13px; } - .ai-analysis-history-card { display: grid; gap: 12px; @@ -2147,21 +1320,17 @@ button:disabled { border-radius: 8px; background: rgba(255, 255, 255, 0.76); } - .ai-analysis-history-card[hidden] { display: none; } - .ai-analysis-history-card h3 { margin: 0; font-size: 18px; } - .ai-analysis-history-list { display: grid; gap: 10px; } - .ai-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2172,13 +1341,11 @@ button:disabled { border-radius: 8px; background: rgba(247, 250, 255, 0.72); } - .ai-history-item div { display: grid; gap: 4px; min-width: 0; } - .ai-history-item span, .ai-history-item p { margin: 0; @@ -2186,7 +1353,6 @@ button:disabled { font-size: 13px; line-height: 1.6; } - .runtime-log-card { display: grid; gap: 12px; @@ -2199,12 +1365,10 @@ button:disabled { var(--surface-solid); box-shadow: var(--shadow-soft); } - .runtime-log-card h3 { margin: 0; font-size: 16px; } - .runtime-log-card pre { min-height: 320px; max-height: 520px; @@ -2225,41 +1389,33 @@ button:disabled { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95); scrollbar-color: rgba(37, 111, 255, 0.38) rgba(234, 242, 255, 0.72); } - .runtime-log-card pre::-webkit-scrollbar { width: 10px; } - .runtime-log-card pre::-webkit-scrollbar-track { background: rgba(234, 242, 255, 0.72); border-radius: 999px; } - .runtime-log-card pre::-webkit-scrollbar-thumb { border: 3px solid rgba(234, 242, 255, 0.72); border-radius: 999px; background: rgba(37, 111, 255, 0.36); } - .runtime-log-card pre::-webkit-scrollbar-thumb:hover { background: rgba(37, 111, 255, 0.52); } - #runtime-log-state[data-status="running"] { background: var(--blue-soft); color: var(--blue); } - #runtime-log-state[data-status="completed"] { background: var(--green-soft); color: var(--green); } - #runtime-log-state[data-status="failed"] { background: var(--red-soft); color: var(--red); } - .modal-actions { position: sticky; bottom: 0; @@ -2270,11 +1426,9 @@ button:disabled { padding-top: 14px; background: rgba(255, 255, 255, 0.96); } - .modal-actions .button-row { flex: 0 0 auto; } - .preview-panel { position: sticky; top: 18px; @@ -2288,7 +1442,6 @@ button:disabled { overflow: hidden; padding: 16px; } - .preview-sticky-core { display: grid; gap: 12px; @@ -2297,7 +1450,6 @@ button:disabled { border-bottom: 1px solid var(--line); scroll-margin-top: 18px; } - .video-placeholder { display: grid; min-height: 230px; @@ -2312,18 +1464,15 @@ button:disabled { color: #fff; text-align: center; } - .video-placeholder span { color: #b9c2d0; } - .video-preview { display: grid; gap: 12px; margin-bottom: 0; min-width: 0; } - .video-preview video { display: block; width: 100%; @@ -2334,25 +1483,21 @@ button:disabled { background: #111927; object-fit: contain; } - .preview-panel .video-placeholder { height: clamp(340px, 30vw, 520px); min-height: 260px; max-height: 44vh; margin-bottom: 0; } - .video-preview-meta { display: grid; gap: 4px; } - .video-preview-meta span { color: #667085; font-size: 13px; line-height: 1.6; } - .play-button { width: 54px; height: 54px; @@ -2361,7 +1506,6 @@ button:disabled { background: transparent; position: relative; } - .play-button::after { position: absolute; top: 15px; @@ -2373,7 +1517,6 @@ button:disabled { border-left: 15px solid #fff; content: ""; } - .preview-timeline { position: relative; height: 42px; @@ -2383,7 +1526,6 @@ button:disabled { repeating-linear-gradient(90deg, #e6ebf3 0 6px, transparent 6px 12px), #f6f8fb; } - .preview-timeline span { position: absolute; top: 10px; @@ -2391,7 +1533,6 @@ button:disabled { border-radius: 6px; background: rgba(37, 111, 255, 0.65); } - .preview-action-notes { gap: 8px; max-height: 72px; @@ -2400,14 +1541,12 @@ button:disabled { font-size: 13px; line-height: 1.45; } - .preview-panel .review-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } - .review-action-button { width: 100%; min-height: 48px; @@ -2417,54 +1556,31 @@ button:disabled { text-align: center; box-shadow: var(--shadow-soft); } - .preview-panel .review-actions .secondary-button { border-color: rgba(37, 111, 255, 0.24); background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(234, 242, 255, 0.88)); color: #1d5fe8; } - .preview-panel .review-actions .review-action-main { min-height: 52px; box-shadow: 0 16px 28px rgba(37, 111, 255, 0.26); } - .preview-panel .form-hint { min-height: 18px; } - .preview-panel .output-list { min-height: 0; margin-top: 16px; overflow: auto; padding-right: 2px; } - -.transcript-drawer { - position: fixed; - top: 0; - right: 0; - z-index: 30; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: min(430px, 100vw); - height: 100vh; - padding: 22px; - border-left: 1px solid var(--line); - background: rgba(255, 255, 255, 0.96); - box-shadow: -18px 0 50px rgba(17, 25, 39, 0.16); - backdrop-filter: blur(18px); -} - .transcript-drawer[hidden] { display: none !important; } - body.transcript-drawer-open .main-panel { padding-right: min(450px, 34vw); } - .transcript-drawer-header { display: flex; align-items: start; @@ -2473,12 +1589,10 @@ body.transcript-drawer-open .main-panel { padding-bottom: 16px; border-bottom: 1px solid var(--line); } - .transcript-drawer-header h2 { margin: 0; font-size: 20px; } - .transcript-drawer-header span { display: inline-block; margin-top: 6px; @@ -2486,7 +1600,6 @@ body.transcript-drawer-open .main-panel { font-size: 13px; font-weight: 700; } - .transcript-drawer-body { display: grid; align-content: start; @@ -2494,12 +1607,10 @@ body.transcript-drawer-open .main-panel { overflow: auto; padding-top: 16px; } - .source-monitor-backdrop { z-index: 42; background: rgba(5, 9, 16, 0.58); } - .source-monitor-panel { width: min(1080px, calc(100vw - 34px)); max-height: min(900px, calc(100vh - 34px)); @@ -2509,7 +1620,6 @@ body.transcript-drawer-open .main-panel { color: #eef2f7; box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42); } - .source-monitor-head, .source-monitor-footer { display: flex; @@ -2520,32 +1630,26 @@ body.transcript-drawer-open .main-panel { border-color: rgba(255, 255, 255, 0.08); background: #242424; } - .source-monitor-head { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } - .source-monitor-head h2, .source-monitor-head p { margin: 0; } - .source-monitor-head .eyebrow { color: #8fbaff; } - .source-monitor-close { color: #d6dce6; background: rgba(255, 255, 255, 0.06); } - .source-monitor-body { display: grid; grid-template-rows: auto auto; min-height: 0; overflow: auto; } - .source-monitor-body video, .source-monitor-empty { width: 100%; @@ -2556,14 +1660,12 @@ body.transcript-drawer-open .main-panel { background: #070b12; object-fit: contain; } - .source-monitor-console { display: grid; gap: 12px; padding: 14px 18px 18px; background: #222; } - .source-monitor-status, .source-monitor-times, .source-monitor-controls, @@ -2573,18 +1675,15 @@ body.transcript-drawer-open .main-panel { align-items: center; gap: 10px; } - .source-monitor-status { justify-content: space-between; } - .source-monitor-status strong { min-width: 128px; color: #2f9bff; font-family: "SFMono-Regular", Consolas, "Microsoft YaHei UI", monospace; font-size: 20px; } - .source-monitor-status span, .source-monitor-times, .source-monitor-message { @@ -2592,7 +1691,6 @@ body.transcript-drawer-open .main-panel { font-family: "SFMono-Regular", Consolas, "Microsoft YaHei UI", monospace; font-size: 13px; } - .source-monitor-zoom { min-width: 128px; min-height: 30px; @@ -2601,7 +1699,6 @@ body.transcript-drawer-open .main-panel { background: #1d1d1d; color: #f8fafc; } - .source-monitor-track { position: relative; height: 82px; @@ -2612,7 +1709,6 @@ body.transcript-drawer-open .main-panel { linear-gradient(180deg, #2c2c2c, #242424); user-select: none; } - .source-monitor-track::before { position: absolute; right: 0; @@ -2623,7 +1719,6 @@ body.transcript-drawer-open .main-panel { content: ""; opacity: 0.78; } - .source-monitor-slider { position: relative; z-index: 1; @@ -2633,29 +1728,24 @@ body.transcript-drawer-open .main-panel { background: #3b3b3b; box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.16); } - .source-monitor-slider.noUi-target { border: 0; box-shadow: none; } - .source-monitor-slider .noUi-base, .source-monitor-slider .noUi-connects { height: 30px; } - .source-monitor-slider .noUi-connects { border-radius: 4px; overflow: visible; } - .source-monitor-slider .noUi-connect { border: 1px solid rgba(68, 152, 255, 0.9); border-radius: 4px; background: rgba(63, 142, 241, 0.3); box-shadow: inset 0 0 0 1px rgba(147, 197, 253, 0.25); } - .source-monitor-slider .noUi-handle { top: 0; right: -6px; @@ -2667,40 +1757,32 @@ body.transcript-drawer-open .main-panel { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.34), 0 6px 18px rgba(0, 0, 0, 0.26); cursor: ew-resize; } - .source-monitor-slider .noUi-handle::before, .source-monitor-slider .noUi-handle::after { display: none; } - .source-monitor-slider .noUi-touch-area { height: 100%; } - .source-monitor-slider[data-disabled="true"] { opacity: 0.45; } - .source-monitor-slider .noUi-pips-horizontal { top: 34px; height: 34px; padding: 0; color: #8f99a8; } - .source-monitor-slider .noUi-marker-horizontal.noUi-marker { width: 1px; background: #6b6b6b; } - .source-monitor-slider .noUi-marker-horizontal.noUi-marker-large { height: 18px; } - .source-monitor-slider .noUi-value-horizontal { display: none; } - .source-monitor-playhead { position: absolute; z-index: 2; @@ -2713,7 +1795,6 @@ body.transcript-drawer-open .main-panel { background: #268cff; cursor: pointer; } - .source-monitor-playhead::after { position: absolute; top: 23px; @@ -2723,27 +1804,22 @@ body.transcript-drawer-open .main-panel { background: rgba(38, 140, 255, 0.9); content: ""; } - .source-monitor-times { justify-content: space-between; } - .source-monitor-controls { flex-direction: column; align-items: stretch; } - .source-monitor-actions, .source-monitor-stepper { flex-wrap: wrap; } - .source-monitor-stepper span { color: #cbd5e1; font-size: 13px; font-weight: 800; } - .source-monitor-step { min-height: 32px; padding: 0 10px; @@ -2755,37 +1831,30 @@ body.transcript-drawer-open .main-panel { font-weight: 800; cursor: pointer; } - .source-monitor-step.active { border-color: rgba(47, 155, 255, 0.72); background: rgba(47, 155, 255, 0.2); color: #9cc9ff; } - .source-monitor-actions .secondary-button { border-color: rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.08); color: #eef2f7; } - .source-monitor-message { min-height: 20px; margin: 0; } - .source-monitor-message[data-tone="error"] { color: #fca5a5; } - .source-monitor-message[data-tone="success"] { color: #86efac; } - .source-monitor-footer { justify-content: flex-end; border-top: 1px solid rgba(255, 255, 255, 0.08); } - .transcript-line { display: grid; gap: 6px; @@ -2794,25 +1863,21 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .transcript-line time { color: var(--blue); font-size: 12px; font-weight: 800; } - .transcript-line p { margin: 0; color: var(--text); line-height: 1.7; } - .output-list { display: grid; gap: 12px; margin-top: 20px; } - .output-list .compact-list li, .output-row { display: grid; @@ -2822,27 +1887,23 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .output-table { display: grid; gap: 10px; margin-top: 16px; } - .output-row p, .empty-note { margin: 0; color: var(--muted); line-height: 1.6; } - .subtitle-workflow-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; } - .subtitle-queue, .subtitle-side, .subtitle-task-list, @@ -2851,52 +1912,43 @@ body.transcript-drawer-open .main-panel { display: grid; gap: 14px; } - .subtitle-side { position: sticky; top: 18px; } - .subtitle-task-card { display: grid; gap: 16px; padding: 16px 0; border-top: 1px solid var(--line); } - .subtitle-task-card:first-child { border-top: 0; padding-top: 0; } - .subtitle-task-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } - .subtitle-task-head h3, .subtitle-task-head p { margin: 8px 0 0; } - .subtitle-task-head p, .subtitle-output-body p { color: var(--muted); line-height: 1.55; } - .subtitle-output-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; } - .subtitle-output-grid-wide { grid-template-columns: repeat(2, minmax(280px, 1fr)); } - .subtitle-output-card { display: grid; gap: 12px; @@ -2905,7 +1957,6 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .subtitle-output-card video { display: block; width: 100%; @@ -2915,28 +1966,23 @@ body.transcript-drawer-open .main-panel { background: #111927; object-fit: contain; } - .subtitled-preview { display: grid; gap: 8px; padding-top: 10px; border-top: 1px solid var(--line); } - .subtitled-preview video { height: 180px; } - .cut-edit-panel { width: min(860px, 100%); } - .cut-edit-body { display: grid; gap: 16px; overflow: auto; } - .cut-edit-body video { width: 100%; max-height: 54vh; @@ -2945,7 +1991,6 @@ body.transcript-drawer-open .main-panel { background: #111927; object-fit: contain; } - .trim-editor-meta, .trim-time-scale { display: flex; @@ -2954,12 +1999,10 @@ body.transcript-drawer-open .main-panel { gap: 12px; color: var(--muted); } - .trim-editor-meta strong { color: var(--text); font-size: 13px; } - .phone-trim-editor { display: grid; gap: 10px; @@ -2968,7 +2011,6 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: #111318; } - .trim-frame-track { position: relative; display: grid; @@ -2980,7 +2022,6 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: #080a0f; } - .trim-frame-track::before { content: "▶"; display: grid; @@ -2992,7 +2033,6 @@ body.transcript-drawer-open .main-panel { font-size: 34px; z-index: 1; } - .trim-frame-track span { border-radius: 6px; background: @@ -3000,7 +2040,6 @@ body.transcript-drawer-open .main-panel { linear-gradient(90deg, #263247, #5f748d 45%, #27364d); opacity: 0.82; } - .trim-outside { position: absolute; top: 6px; @@ -3008,15 +2047,12 @@ body.transcript-drawer-open .main-panel { background: rgba(0, 0, 0, 0.5); pointer-events: none; } - .trim-outside-left { left: 0; } - .trim-outside-right { right: 0; } - .trim-selection { position: absolute; top: 0; @@ -3028,7 +2064,6 @@ body.transcript-drawer-open .main-panel { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.45) inset, 0 8px 18px rgba(0, 0, 0, 0.2); pointer-events: none; } - .trim-handle { position: absolute; top: 0; @@ -3042,7 +2077,6 @@ body.transcript-drawer-open .main-panel { cursor: ew-resize; pointer-events: auto; } - .trim-handle::before { content: ""; width: 5px; @@ -3050,7 +2084,6 @@ body.transcript-drawer-open .main-panel { border-radius: 99px; background: rgba(255, 255, 255, 0.86); } - .trim-handle span { position: absolute; width: 1px; @@ -3058,39 +2091,32 @@ body.transcript-drawer-open .main-panel { overflow: hidden; clip: rect(0 0 0 0); } - .trim-handle-start { left: -6px; border-radius: 8px 0 0 8px; } - .trim-handle-end { right: -6px; border-radius: 0 8px 8px 0; } - .trim-playhead-range { width: 100%; accent-color: #ffd43b; } - .trim-time-scale { color: rgba(255, 255, 255, 0.7); font-variant-numeric: tabular-nums; } - .trim-time-editor { display: grid; grid-template-columns: repeat(2, minmax(150px, 1fr)) repeat(2, auto); gap: 10px; align-items: end; } - .trim-time-editor label { display: grid; gap: 6px; } - .trim-frame-track { display: flex; align-items: stretch; @@ -3098,11 +2124,9 @@ body.transcript-drawer-open .main-panel { height: 96px; padding: 8px; } - .trim-frame-track::before { content: none; } - .trim-strip-play { position: relative; z-index: 4; @@ -3116,7 +2140,6 @@ body.transcript-drawer-open .main-panel { background: #0f1117; cursor: pointer; } - .trim-strip-play::before { content: ""; width: 0; @@ -3126,7 +2149,6 @@ body.transcript-drawer-open .main-panel { border-bottom: 18px solid transparent; border-left: 28px solid #ffffff; } - .trim-frame-strip { position: relative; flex: 1; @@ -3136,7 +2158,6 @@ body.transcript-drawer-open .main-panel { min-width: 0; padding-left: 3px; } - .trim-frame-strip span { border-radius: 5px; background: @@ -3144,7 +2165,6 @@ body.transcript-drawer-open .main-panel { linear-gradient(90deg, #202b3a, #587089 45%, #1d2938); opacity: 0.86; } - .trim-slider { position: absolute; z-index: 3; @@ -3157,27 +2177,22 @@ body.transcript-drawer-open .main-panel { background: transparent; box-shadow: none; } - .trim-slider.noUi-target { border: 0; box-shadow: none; } - .trim-slider .noUi-base, .trim-slider .noUi-connects { height: 100%; } - .trim-slider .noUi-connects { overflow: visible; } - .trim-slider .noUi-connect { border-radius: 8px; background: rgba(255, 212, 59, 0.16); box-shadow: inset 0 0 0 6px #ffd43b, 0 8px 18px rgba(0, 0, 0, 0.22); } - .trim-slider .noUi-handle { top: 0; right: -17px; @@ -3189,7 +2204,6 @@ body.transcript-drawer-open .main-panel { box-shadow: none; cursor: ew-resize; } - .trim-slider .noUi-handle::before { content: ""; position: absolute; @@ -3202,15 +2216,12 @@ body.transcript-drawer-open .main-panel { background: rgba(255, 255, 255, 0.86); transform: translate(-50%, -50%); } - .trim-slider .noUi-handle::after { display: none; } - .trim-slider[data-disabled="true"] { opacity: 0.5; } - .trim-playhead { position: absolute; z-index: 5; @@ -3223,20 +2234,16 @@ body.transcript-drawer-open .main-panel { background: rgba(255, 255, 255, 0.92); pointer-events: none; } - #cut-edit-save-message[data-tone="success"] { color: var(--green); } - #cut-edit-save-message[data-tone="error"] { color: var(--red); } - .compact-video-placeholder { min-height: 220px; margin-bottom: 0; } - .subtitle-output-actions, .publish-account-row { display: flex; @@ -3244,18 +2251,15 @@ body.transcript-drawer-open .main-panel { gap: 8px; flex-wrap: wrap; } - .compact-settings-form { overflow: visible; padding-right: 0; } - .compact-settings-form input[type="color"] { width: 100%; min-height: 44px; padding: 4px; } - .publish-account-row { justify-content: space-between; padding: 10px; @@ -3263,27 +2267,15 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - -.inline-alert { - margin-bottom: 18px; - padding: 12px 14px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--surface-solid); - color: var(--muted); -} - .inline-alert.tone-blue { border-color: rgba(37, 111, 255, 0.18); background: var(--blue-soft); color: var(--blue); } - .publish-console { display: grid; gap: 18px; } - .publish-tabs { display: inline-flex; width: fit-content; @@ -3294,7 +2286,6 @@ body.transcript-drawer-open .main-panel { background: var(--surface-solid); box-shadow: var(--shadow-soft); } - .publish-tabs button { min-height: 36px; padding: 0 16px; @@ -3303,29 +2294,24 @@ body.transcript-drawer-open .main-panel { background: transparent; cursor: pointer; } - .publish-tabs button.active { color: var(--blue); background: var(--blue-soft); font-weight: 700; } - .publish-platform-panel { display: none; gap: 18px; } - .publish-platform-panel.active { display: grid; } - .publish-backend-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); gap: 18px; align-items: start; } - .publish-account-list, .publish-job-list, .publish-queue, @@ -3333,22 +2319,18 @@ body.transcript-drawer-open .main-panel { display: grid; gap: 12px; } - .publish-account-row strong, .publish-account-row span { display: block; } - .publish-account-row span { margin-top: 4px; color: var(--muted); font-size: 12px; } - .publish-task-panel { overflow: hidden; } - .publish-batch-bar { display: grid; grid-template-columns: auto minmax(140px, 180px) minmax(140px, 180px) minmax(160px, 1fr) minmax(160px, 1fr) auto auto; @@ -3360,12 +2342,10 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .publish-batch-bar .form-hint { grid-column: 1 / -1; margin: 0; } - .publish-card { padding: 14px; border: 1px solid var(--line); @@ -3373,12 +2353,10 @@ body.transcript-drawer-open .main-panel { background: var(--surface-solid); box-shadow: var(--shadow-soft); } - .publish-card form { display: grid; gap: 14px; } - .publish-card-main, .publish-actions, .publish-job-row { @@ -3387,13 +2365,11 @@ body.transcript-drawer-open .main-panel { justify-content: space-between; gap: 14px; } - .publish-card-main h3, .publish-job-row h3 { margin: 8px 0 4px; font-size: 17px; } - .publish-card-main p, .publish-job-row p { margin: 0; @@ -3401,7 +2377,6 @@ body.transcript-drawer-open .main-panel { font-size: 13px; overflow-wrap: anywhere; } - .publish-select-line { display: inline-flex; align-items: center; @@ -3410,13 +2385,11 @@ body.transcript-drawer-open .main-panel { color: var(--muted); font-size: 13px; } - .publish-source-grid { display: grid; grid-template-columns: repeat(2, minmax(160px, 1fr)); gap: 10px; } - .publish-choice { display: grid; gap: 6px; @@ -3427,41 +2400,33 @@ body.transcript-drawer-open .main-panel { background: var(--surface-muted); cursor: pointer; } - .publish-choice:has(input:checked) { border-color: rgba(37, 111, 255, 0.42); background: var(--blue-soft); } - .publish-choice small { color: var(--muted); } - .publish-choice.is-disabled { opacity: 0.54; cursor: not-allowed; } - .publish-fields { grid-template-columns: repeat(2, minmax(220px, 1fr)); } - .publish-fields label, .publish-fields .form-row, .publish-fields .toggle-line { min-width: 0; } - .publish-fields label:nth-child(3), .publish-fields label:nth-child(4) { grid-column: span 2; } - .publish-fields textarea { min-height: 92px; resize: vertical; } - .publish-cover-box { display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); @@ -3472,7 +2437,6 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .publish-cover-preview { position: relative; display: grid; @@ -3489,7 +2453,6 @@ body.transcript-drawer-open .main-panel { font-weight: 800; aspect-ratio: 16 / 9; } - .publish-cover-preview img, .publish-job-cover { display: block; @@ -3498,75 +2461,61 @@ body.transcript-drawer-open .main-panel { object-fit: cover; aspect-ratio: 16 / 9; } - .publish-cover-preview img { height: 100%; } - .publish-cover-controls { display: grid; align-content: center; gap: 10px; } - .publish-cover-controls .form-hint { margin: 0; } - .publish-job-cover { max-width: 220px; margin-top: 10px; border: 1px solid var(--line); } - .publish-actions { padding-top: 10px; border-top: 1px solid var(--line); flex-wrap: wrap; } - .publish-actions .form-hint { flex: 1 1 260px; margin: 0; } - .publish-job-row { padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-muted); } - .publish-job-row > div:first-child { min-width: 0; } - .inline-alert.tone-red { border-color: rgba(225, 82, 82, 0.18); background: var(--red-soft); color: var(--red); } - .send-heading { align-items: flex-start; } - .send-stat-grid { margin-bottom: 18px; } - .send-center-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); gap: 18px; align-items: start; } - .send-main-panel, .send-history-panel { overflow: hidden; } - .send-toolbar { display: flex; gap: 8px; @@ -3577,7 +2526,6 @@ body.transcript-drawer-open .main-panel { border-radius: 8px; background: var(--surface-muted); } - .send-toolbar button { min-height: 34px; padding: 0 12px; @@ -3586,7 +2534,6 @@ body.transcript-drawer-open .main-panel { color: var(--muted); cursor: pointer; } - .send-toolbar button.active, .send-toolbar button:hover { background: var(--surface-solid); @@ -3594,7 +2541,6 @@ body.transcript-drawer-open .main-panel { font-weight: 700; box-shadow: var(--shadow-soft); } - .send-select-all, .send-checkbox { display: inline-flex; @@ -3604,12 +2550,10 @@ body.transcript-drawer-open .main-panel { font-size: 13px; white-space: nowrap; } - .send-queue { display: grid; gap: 16px; } - .send-card { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); @@ -3620,22 +2564,18 @@ body.transcript-drawer-open .main-panel { background: var(--surface-solid); box-shadow: var(--shadow-soft); } - .send-card.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 { display: none; } - .send-card-media { display: grid; align-content: start; gap: 10px; } - .send-card-media video, .send-cover-preview { display: block; @@ -3646,11 +2586,9 @@ body.transcript-drawer-open .main-panel { background: #101826; aspect-ratio: 16 / 9; } - .send-card-media video { object-fit: contain; } - .send-cover-preview { display: grid; place-items: center; @@ -3661,19 +2599,16 @@ body.transcript-drawer-open .main-panel { font-size: 13px; font-weight: 700; } - .send-cover-preview img { width: 100%; height: 100%; object-fit: cover; } - .send-card-form { display: grid; gap: 14px; min-width: 0; } - .send-card-header, .send-actions { display: flex; @@ -3681,54 +2616,45 @@ body.transcript-drawer-open .main-panel { justify-content: space-between; gap: 12px; } - .send-card-header h3 { margin: 8px 0 4px; font-size: 18px; overflow-wrap: anywhere; } - .send-card-header p { margin: 0; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } - .send-meta-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } - .send-fields, .send-platform-options { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 12px; } - .send-fields label, .send-platform-options label { min-width: 0; } - .send-fields .span-2 { grid-column: span 2; } - .send-fields textarea { min-height: 94px; resize: vertical; } - .send-platform-options .toggle-line { min-height: 44px; align-items: center; align-self: end; } - .cover-frame-list { display: flex; align-items: center; @@ -3742,7 +2668,6 @@ body.transcript-drawer-open .main-panel { color: var(--muted); font-size: 13px; } - .cover-frame-option { position: relative; width: 104px; @@ -3754,14 +2679,12 @@ body.transcript-drawer-open .main-panel { cursor: pointer; aspect-ratio: 16 / 9; } - .cover-frame-option img { display: block; width: 100%; height: 100%; object-fit: cover; } - .cover-frame-option span { position: absolute; right: 4px; @@ -3772,34 +2695,28 @@ body.transcript-drawer-open .main-panel { color: #fff; font-size: 11px; } - .cover-frame-option.active { border-color: var(--blue); } - .send-actions { align-items: center; padding-top: 12px; border-top: 1px solid var(--line); flex-wrap: wrap; } - .send-actions .form-hint { flex: 1 1 260px; margin: 0; } - .send-preview-panel { position: sticky; top: 20px; } - .phone-preview { display: grid; place-items: center; padding: 10px; } - .phone-screen { position: relative; width: min(100%, 230px); @@ -3810,14 +2727,12 @@ body.transcript-drawer-open .main-panel { box-shadow: 0 16px 32px rgba(17, 24, 39, 0.2); aspect-ratio: 9 / 16; } - .phone-screen img, .phone-screen video { width: 100%; height: 100%; object-fit: cover; } - .phone-caption { position: absolute; right: 0; @@ -3829,17 +2744,14 @@ body.transcript-drawer-open .main-panel { background: linear-gradient(180deg, transparent, rgba(8, 11, 18, 0.92)); color: #fff; } - .phone-caption strong, .phone-caption span { overflow-wrap: anywhere; } - .phone-caption span { color: rgba(255, 255, 255, 0.78); font-size: 12px; } - .send-preview-copy { display: grid; gap: 10px; @@ -3847,11 +2759,9 @@ body.transcript-drawer-open .main-panel { font-size: 13px; line-height: 1.7; } - .send-preview-copy p { margin: 0; } - .send-publishing-overlay { position: fixed; inset: 0; @@ -3862,11 +2772,9 @@ body.transcript-drawer-open .main-panel { background: rgba(15, 23, 42, 0.28); backdrop-filter: blur(4px); } - .send-publishing-overlay[hidden] { display: none; } - .send-publishing-box { display: grid; justify-items: center; @@ -3880,18 +2788,15 @@ body.transcript-drawer-open .main-panel { text-align: center; box-shadow: 0 22px 54px rgba(15, 23, 42, 0.28); } - .send-publishing-box strong { font-size: 18px; } - .send-publishing-box p { margin: 0; color: rgba(255, 255, 255, 0.72); font-size: 13px; line-height: 1.6; } - .send-publishing-spinner { width: 32px; height: 32px; @@ -3906,238 +2811,174 @@ body.transcript-drawer-open .main-panel { transform: rotate(360deg); } } - .error-text { color: var(--red); overflow-wrap: anywhere; } @media (max-height: 780px) and (min-width: 1181px) { - .video-preview video, - .preview-panel .video-placeholder { +.video-preview video, +.preview-panel .video-placeholder { max-height: 39vh; } - - .preview-action-notes { +.preview-action-notes { max-height: 40px; } } @media (max-width: 1180px) { - .stat-grid, - .review-stat-grid, - .workflow-line, - .detail-grid, - .content-grid, - .split-layout, - .subtitle-workflow-grid, - .subtitle-output-grid, - .review-layout, - .review-task-row, - .review-metrics, - .send-center-grid, - .send-card, - .send-fields, - .send-platform-options, - .publish-backend-grid, - .publish-batch-bar, - .publish-cover-box, - .publish-fields, - .publish-source-grid { +.review-stat-grid, +.workflow-line, +.detail-grid, +.content-grid, +.split-layout, +.subtitle-workflow-grid, +.subtitle-output-grid, +.review-layout, +.review-task-row, +.review-metrics, +.send-center-grid, +.send-card, +.send-fields, +.send-platform-options, +.publish-backend-grid, +.publish-batch-bar, +.publish-cover-box, +.publish-fields, +.publish-source-grid { grid-template-columns: 1fr; } - - .publish-fields label:nth-child(3), - .publish-fields label:nth-child(4) { +.publish-fields label:nth-child(3), +.publish-fields label:nth-child(4) { grid-column: span 1; } - - .send-fields .span-2 { +.send-fields .span-2 { grid-column: span 1; } - - .prompt-preset-tabs, - .ai-analysis-controls, - .ai-analysis-result-header, - .ai-analysis-result-meta, - .ai-history-item { +.prompt-preset-tabs, +.ai-analysis-controls, +.ai-analysis-result-header, +.ai-analysis-result-meta, +.ai-history-item { align-items: stretch; grid-template-columns: 1fr; flex-direction: column; } - - .ai-analysis-controls label { +.ai-analysis-controls label { max-width: none; } - - .prompt-preset-tab { +.prompt-preset-tab { border-radius: 8px; border: 1px solid var(--line); } - - .prompt-preset-card { +.prompt-preset-card { border-radius: 8px; } - - .process-panel, - .transcript-panel, - .detail-grid > aside { +.process-panel, +.transcript-panel, +.detail-grid > aside { grid-column: span 1; grid-row: auto; } - - .preview-panel { +.preview-panel { position: static; max-height: none; overflow: visible; } - - .preview-panel .output-list { +.preview-panel .output-list { overflow: visible; } - - .subtitle-side { +.subtitle-side { position: static; } - - .send-preview-panel { +.send-preview-panel { position: static; } - - body.transcript-drawer-open .main-panel { +body.transcript-drawer-open .main-panel { padding-right: 0; } } @media (max-width: 860px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: static; - height: auto; - } - - .sidebar-note { - position: static; - margin-top: 18px; - } - - .topbar, - .page-heading, - .panel-header, - .subtitle-task-head { +.task-detail-support { grid-template-columns: minmax(0, 1fr); } +.subtitle-task-head { align-items: stretch; flex-direction: column; } - - .topbar-actions { - justify-content: flex-start; - } - - .workflow-line::before { +.workflow-line::before { display: none; } - - .metro-timeline { +.metro-timeline { grid-template-columns: repeat(4, minmax(58px, 1fr)); } - - .metro-timeline::before { +.metro-timeline::before { display: none; } } @media (max-width: 640px) { - .main-panel { - padding: 16px; - } - - .page-heading h1 { - font-size: 24px; - } - - .metro-timeline { +.metro-timeline { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .live-current-operation { +.live-current-operation { grid-template-columns: 1fr; } - - .live-current-operation > strong, - .live-current-operation p { +.live-current-operation > strong, +.live-current-operation p { grid-column: 1; } - - .segmented, - .source-grid, - .upload-card, - .path-picker, - .form-row, - .meta-grid, - .clip-copy, - .clip-media-row, - .review-metrics, - .trim-time-editor { +.segmented, +.source-grid, +.upload-card, +.path-picker, +.form-row, +.meta-grid, +.clip-copy, +.clip-media-row, +.review-metrics, +.trim-time-editor { grid-template-columns: 1fr; } - - .trim-frame-track { +.trim-frame-track { grid-template-columns: 58px repeat(8, minmax(38px, 1fr)); } - - .modal-backdrop { +.modal-backdrop { padding: 12px; } - - .modal-panel { - max-height: calc(100vh - 24px); - padding: 16px; - } - - .modal-actions { +.modal-actions { align-items: stretch; flex-direction: column; } - - .preview-panel .review-actions { +.preview-panel .review-actions { grid-template-columns: 1fr; } - - .video-preview video, - .preview-panel .video-placeholder { +.video-preview video, +.preview-panel .video-placeholder { height: 300px; min-height: 300px; max-height: 52vh; } } - .publish-schedule-panel { display: grid; gap: 16px; margin-bottom: 18px; } - .publish-schedule-panel .panel-header p { max-width: 760px; margin: 6px 0 0; color: var(--muted); line-height: 1.65; } - .publish-schedule-form { display: grid; gap: 16px; } - .publish-schedule-grid { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, 1fr)); gap: 12px; } - .publish-schedule-actions { display: flex; align-items: center; @@ -4146,389 +2987,37 @@ body.transcript-drawer-open .main-panel { padding-top: 14px; border-top: 1px solid var(--line); } - .publish-schedule-actions .form-hint { flex: 1 1 320px; margin: 0; } - .schedule-time-note { color: var(--muted); font-size: 12px; } - .schedule-time-note time { color: var(--text); font-weight: 700; } @media (max-width: 980px) { - .publish-schedule-grid { +.publish-schedule-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 640px) { - .publish-schedule-grid { +.publish-schedule-grid { grid-template-columns: 1fr; } - - .publish-schedule-actions { +.publish-schedule-actions { align-items: stretch; flex-direction: column; } } - -/* 2026-06-08 Apple-style redesign layer */ -:root { - --bg: #f5f8fd; - --bg-soft: #edf3fb; - --surface: rgba(255, 255, 255, 0.82); - --surface-solid: #ffffff; - --surface-muted: #f4f7fb; - --surface-deep: #eef4ff; - --text: #172033; - --muted: #6d788b; - --muted-strong: #4b5567; - --line: rgba(31, 45, 71, 0.10); - --line-strong: rgba(31, 45, 71, 0.16); - --blue: #1f6fff; - --blue-deep: #0e5ae8; - --blue-soft: #eaf2ff; - --green: #20a06b; - --green-soft: #e8f8f0; - --amber: #d98724; - --amber-soft: #fff4df; - --red: #df4f56; - --red-soft: #fff0f2; - --purple: #7467d8; - --purple-soft: #f0edff; - --shadow: 0 18px 45px rgba(32, 53, 87, 0.10); - --shadow-soft: 0 9px 24px rgba(32, 53, 87, 0.08); - --shadow-raised: 0 24px 60px rgba(23, 32, 51, 0.14); - --glass-border: rgba(255, 255, 255, 0.72); -} - -html { - background: #f5f8fd; -} - -body { - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(244, 248, 255, 0.96) 42%, rgba(236, 243, 252, 0.98) 100%), - linear-gradient(90deg, rgba(31, 111, 255, 0.035) 1px, transparent 1px), - linear-gradient(180deg, rgba(31, 111, 255, 0.028) 1px, transparent 1px); - background-size: auto, 48px 48px, 48px 48px; - color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif; -} - body::selection { background: rgba(31, 111, 255, 0.18); } - -.app-shell { - grid-template-columns: 250px minmax(0, 1fr); -} - -.sidebar { - padding: 20px 16px; - border-right: 1px solid rgba(31, 45, 71, 0.08); - background: rgba(255, 255, 255, 0.72); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.58); - backdrop-filter: blur(26px); -} - -.brand { - min-height: 62px; - margin: 0 0 24px; - padding: 8px; - border: 1px solid rgba(31, 111, 255, 0.10); - border-radius: 8px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 255, 0.72)); - box-shadow: 0 12px 28px rgba(31, 111, 255, 0.08); -} - -.brand-mark { - width: 42px; - height: 42px; - flex-basis: 42px; - border-radius: 8px; - filter: drop-shadow(0 10px 16px rgba(31, 111, 255, 0.18)); -} - -.brand strong { - color: #111827; - font-size: 15px; -} - -.brand small { - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.side-nav { - gap: 7px; -} - -.side-nav a { - gap: 10px; - min-height: 40px; - padding: 0 10px; - border: 1px solid transparent; - border-radius: 8px; - color: var(--muted-strong); - font-size: 14px; - font-weight: 650; -} - -.side-nav a:hover { - border-color: rgba(31, 111, 255, 0.10); - background: rgba(255, 255, 255, 0.72); - color: var(--blue); - box-shadow: var(--shadow-soft); -} - -.side-nav a.active { - border-color: rgba(31, 111, 255, 0.18); - background: linear-gradient(180deg, rgba(235, 243, 255, 0.98), rgba(222, 235, 255, 0.78)); - color: var(--blue); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86), 0 10px 24px rgba(31, 111, 255, 0.10); -} - -.nav-dot { - display: none; -} - -.nav-icon { - display: grid; - width: 24px; - height: 24px; - flex: 0 0 24px; - place-items: center; - border: 1px solid rgba(31, 45, 71, 0.08); - border-radius: 7px; - background: rgba(255, 255, 255, 0.84); - color: #64748b; - font-size: 12px; - font-weight: 800; -} - -.side-nav a.active .nav-icon, -.side-nav a:hover .nav-icon { - border-color: rgba(31, 111, 255, 0.18); - background: #ffffff; - color: var(--blue); -} - -.sidebar-note { - right: 16px; - bottom: 18px; - left: 16px; - gap: 8px; - padding: 14px; - border-color: rgba(31, 111, 255, 0.12); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(240, 246, 255, 0.84)); - box-shadow: var(--shadow-soft); -} - -.sidebar-note strong { - font-size: 13px; -} - -.sidebar-note span { - color: var(--muted); - font-size: 12px; -} - -.main-panel { - padding: 16px 28px 46px; -} - -.topbar { - position: sticky; - z-index: 30; - top: 0; - min-height: 58px; - margin: 0 -8px 24px; - padding: 8px; - border: 1px solid rgba(31, 45, 71, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.74); - box-shadow: 0 12px 32px rgba(32, 53, 87, 0.08); - backdrop-filter: blur(24px); -} - -.topbar-title { - display: grid; - gap: 2px; - min-width: 138px; - padding: 0 4px; -} - -.topbar-title span { - color: var(--muted); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; -} - -.topbar-title strong { - color: var(--text); - font-size: 15px; -} - -.search-box { - min-height: 38px; - border-color: rgba(31, 45, 71, 0.08); - background: rgba(247, 250, 255, 0.86); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94); -} - -.search-box:focus-within { - border-color: rgba(31, 111, 255, 0.28); - background: #ffffff; - box-shadow: 0 0 0 4px rgba(31, 111, 255, 0.08); -} - -.search-box input::placeholder, -input::placeholder, -textarea::placeholder { - color: #a0aabd; -} - -.system-chip, -.user-chip { - display: inline-flex; - align-items: center; - min-height: 34px; - padding: 0 12px; - border: 1px solid rgba(31, 45, 71, 0.08); - border-radius: 999px; - background: rgba(255, 255, 255, 0.86); - color: var(--muted-strong); - font-size: 12px; - font-weight: 750; - white-space: nowrap; -} - -.notice-dot { - width: 34px; - height: 34px; - border-color: rgba(31, 45, 71, 0.08); - background: rgba(255, 255, 255, 0.86); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88); -} - -.page-heading { - align-items: flex-start; - margin-bottom: 18px; - padding: 2px 0 4px; -} - -.page-heading h1 { - color: #111827; - font-size: 30px; - font-weight: 760; - letter-spacing: 0; -} - -.page-heading p:not(.eyebrow), -.detail-locator { - max-width: 920px; - color: var(--muted); - font-size: 14px; - line-height: 1.65; -} - -.eyebrow { - color: var(--blue); - font-size: 11px; - font-weight: 820; - letter-spacing: 0; - text-transform: uppercase; -} - -.primary-button, -.secondary-button, -.pagination button, -.icon-button { - min-height: 38px; - border-radius: 8px; - font-weight: 760; -} - -.primary-button { - border: 1px solid rgba(14, 90, 232, 0.25); - background: linear-gradient(180deg, #4f91ff, var(--blue-deep)); - box-shadow: 0 12px 22px rgba(31, 111, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.32); -} - -.primary-button:hover { - box-shadow: 0 16px 30px rgba(31, 111, 255, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.34); -} - -.secondary-button, -.pagination button, -.icon-button { - border-color: rgba(31, 45, 71, 0.10); - background: rgba(255, 255, 255, 0.86); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92), 0 8px 18px rgba(32, 53, 87, 0.06); -} - -.secondary-button:hover, -.pagination button:hover:not(:disabled), -.icon-button:hover:not(:disabled) { - border-color: rgba(31, 111, 255, 0.24); - color: var(--blue); - transform: translateY(-1px); -} - -.compact-button { - min-height: 34px; - padding: 0 12px; -} - -.panel, -.stat-card, -.modal-panel, -.transcript-drawer { - border-color: rgba(31, 45, 71, 0.09); - border-radius: 8px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(250, 252, 255, 0.82)); - box-shadow: var(--shadow); - backdrop-filter: blur(22px); -} - -.panel { - padding: 20px; -} - -.panel-header { - align-items: flex-start; -} - -.panel-header h2, -.panel-header h3 { - color: #111827; -} - -.panel-header p:not(.eyebrow) { - color: var(--muted); - line-height: 1.55; -} - -.stat-grid { - gap: 13px; -} - -.stat-card { - min-height: 118px; - padding: 16px; - box-shadow: var(--shadow-soft); -} - .stat-card::after { position: absolute; right: 12px; @@ -4540,34 +3029,6 @@ textarea::placeholder { content: ""; opacity: 0.16; } - -.stat-icon { - width: 30px; - height: 30px; - border-radius: 8px; - background: linear-gradient(180deg, currentColor, rgba(255, 255, 255, 0.32)); - opacity: 0.18; -} - -.stat-card span { - color: var(--muted); - font-size: 12px; - font-weight: 740; -} - -.stat-card strong { - color: #111827; - font-size: 30px; - font-weight: 780; -} - -.stat-card small { - overflow-wrap: anywhere; - color: currentColor; - font-size: 12px; - line-height: 1.45; -} - .content-grid, .split-layout, .review-layout, @@ -4576,18 +3037,15 @@ textarea::placeholder { .subtitle-workflow-grid { gap: 18px; } - .workflow-line::before, .metro-timeline::before { background: linear-gradient(90deg, rgba(31, 111, 255, 0.40), rgba(218, 226, 238, 0.88)); } - .workflow-step span, .timeline-list span, .metro-step span { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); } - .timeline-list li, .review-task-row, .output-row, @@ -4605,79 +3063,6 @@ textarea::placeholder { background: rgba(255, 255, 255, 0.78); box-shadow: var(--shadow-soft); } - -.table-wrap { - border-color: rgba(31, 45, 71, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.86); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); -} - -table { - min-width: 840px; -} - -thead { - background: linear-gradient(180deg, #f6f8fc, #eef3fb); -} - -th { - color: #667085; - font-size: 12px; -} - -td { - color: #263349; -} - -tbody tr:hover { - background: rgba(235, 243, 255, 0.55); -} - -.platform-badge, -.status-pill { - min-height: 26px; - border: 1px solid transparent; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74); -} - -.status-pill { - border-color: rgba(31, 111, 255, 0.10); - background: rgba(234, 242, 255, 0.92); -} - -.status-pill.tone-green { - border-color: rgba(32, 160, 107, 0.13); -} - -.status-pill.tone-amber { - border-color: rgba(217, 135, 36, 0.15); -} - -.status-pill.tone-red { - border-color: rgba(223, 79, 86, 0.14); -} - -.control, -label input, -label select, -label textarea, -fieldset input { - border-color: rgba(31, 45, 71, 0.13); - border-radius: 8px; - background: rgba(255, 255, 255, 0.90); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92); -} - -.control:focus, -label input:focus, -label select:focus, -label textarea:focus, -fieldset input:focus { - border-color: rgba(31, 111, 255, 0.34); - box-shadow: 0 0 0 4px rgba(31, 111, 255, 0.08); -} - .toolbar, .send-toolbar, .publish-batch-bar { @@ -4686,12 +3071,10 @@ fieldset input:focus { border-radius: 8px; background: rgba(245, 248, 253, 0.86); } - .send-toolbar button, .publish-tabs button { font-weight: 720; } - .upload-card, .drop-zone, .nas-zone, @@ -4702,79 +3085,37 @@ fieldset input:focus { border-radius: 8px; background: linear-gradient(180deg, rgba(247, 250, 255, 0.96), rgba(236, 244, 255, 0.72)); } - .upload-card { min-height: 132px; } - -.file-upload-button { - border-color: rgba(31, 111, 255, 0.22); - border-radius: 8px; - background: linear-gradient(180deg, #4f91ff, var(--blue-deep)); - box-shadow: 0 14px 26px rgba(31, 111, 255, 0.22); -} - .unit-input { border-color: rgba(31, 45, 71, 0.13); border-radius: 8px; } - -.page-alert, -.inline-alert { - border-color: rgba(31, 111, 255, 0.12); - border-radius: 8px; - background: rgba(234, 242, 255, 0.88); - box-shadow: var(--shadow-soft); -} - -.empty-state { - border: 1px solid rgba(31, 45, 71, 0.08); - border-radius: 8px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(244, 248, 253, 0.86)); - box-shadow: var(--shadow-soft); -} - .review-task-row { padding: 16px; } - .review-task-main h3, .send-card-header h3, .publish-job-row h3 { color: #111827; } - .review-metrics div { border-color: rgba(31, 45, 71, 0.08); background: rgba(246, 249, 254, 0.86); } - .detail-grid { gap: 18px; } - .summary-card, .status-overview-card { - min-height: 224px; + min-height: 0; } - .meta-grid div, .clip-copy div { border-color: rgba(31, 45, 71, 0.08); background: rgba(247, 250, 255, 0.82); } - -.large-progress, -.progress { - background: rgba(219, 228, 242, 0.86); - box-shadow: inset 0 1px 2px rgba(31, 45, 71, 0.08); -} - -.progress span, -.large-progress span { - background: linear-gradient(90deg, #1f6fff, #64a2ff); -} - .transcript-box, .transcript-full-text, #runtime-log-lines { @@ -4783,37 +3124,29 @@ fieldset input:focus { background: rgba(247, 250, 255, 0.84); color: #263349; } - #runtime-log-lines { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92); } - .prompt-preset-tabs { background: rgba(245, 248, 253, 0.92); } - .prompt-preset-tab.active { background: #ffffff; box-shadow: var(--shadow-soft); } - .review-layout { grid-template-columns: minmax(0, 1.55fr) minmax(390px, 0.82fr); } - .clip-list { gap: 14px; } - .clip-card { gap: 15px; } - .clip-card-header { padding-bottom: 4px; border-bottom: 1px solid rgba(31, 45, 71, 0.07); } - .clip-index { display: inline-grid; width: 34px; @@ -4826,7 +3159,6 @@ fieldset input:focus { font-size: 12px; font-weight: 820; } - .thumb-placeholder, .video-placeholder, .send-cover-preview, @@ -4838,35 +3170,28 @@ fieldset input:focus { #111827; color: rgba(255, 255, 255, 0.88); } - .thumb-placeholder:hover { box-shadow: 0 16px 30px rgba(31, 111, 255, 0.16); } - .clip-card-tools { border-color: rgba(31, 45, 71, 0.08); border-radius: 8px; background: rgba(246, 249, 254, 0.86); } - .preview-panel { top: 78px; max-height: calc(100vh - 96px); } - .video-preview { border-radius: 8px; box-shadow: var(--shadow-soft); } - .video-preview video { background: #0f172a; } - .preview-timeline { background: rgba(236, 244, 255, 0.84); } - .preview-panel .review-actions { gap: 10px; padding: 10px; @@ -4874,39 +3199,25 @@ fieldset input:focus { border-radius: 8px; background: rgba(246, 249, 254, 0.88); } - -.transcript-drawer { - background: rgba(255, 255, 255, 0.94); -} - .modal-backdrop, .send-publishing-overlay { background: rgba(20, 31, 48, 0.32); backdrop-filter: blur(10px); } - -.modal-panel { - box-shadow: var(--shadow-raised); -} - .source-monitor-panel, .source-monitor-console { background: #111827; } - .source-monitor-panel { border-color: rgba(255, 255, 255, 0.10); } - .source-monitor-console, .phone-trim-editor { border-color: rgba(255, 255, 255, 0.12); } - .subtitle-output-card { overflow: hidden; } - .subtitle-output-card video, .send-card-media video, .send-cover-preview, @@ -4914,276 +3225,104 @@ fieldset input:focus { border-radius: 8px; box-shadow: var(--shadow-soft); } - .subtitle-side, .send-preview-panel { top: 78px; } - .send-card { grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); padding: 16px; } - .send-card.is-previewing { border-color: rgba(31, 111, 255, 0.26); box-shadow: 0 22px 44px rgba(31, 111, 255, 0.13); } - .send-card-media { gap: 12px; } - .send-card-media video, .send-cover-preview { background: #0f172a; } - .send-fields, .send-platform-options { gap: 12px; } - .phone-preview { padding: 8px 0 14px; } - .phone-screen { border-width: 7px; border-radius: 24px; box-shadow: 0 22px 44px rgba(17, 24, 39, 0.22); } - .phone-caption { background: linear-gradient(180deg, transparent, rgba(8, 12, 20, 0.94)); } - .send-publishing-box { border-radius: 8px; background: rgba(17, 24, 39, 0.94); } - .system-grid .stat-card { min-height: 128px; } - .ai-interface-grid { gap: 14px; } - .ai-interface-section { background: rgba(247, 250, 255, 0.84); } - .settings-section h3 { color: #111827; } - .action-links { gap: 10px; flex-wrap: wrap; } - .action-links a, td a, -.link-button, .text-link { color: var(--blue); font-weight: 760; } - -.link-button.danger, .error-text { color: var(--red); } -.pagination span { - border: 1px solid rgba(31, 111, 255, 0.12); - background: #ffffff; - box-shadow: var(--shadow-soft); -} - -@media (max-width: 1280px) { - .app-shell { - grid-template-columns: 230px minmax(0, 1fr); - } -} - @media (max-width: 1180px) { - .topbar { - position: static; - align-items: stretch; - flex-direction: column; - } - - .topbar-title { - min-width: 0; - } - - .search-box { - width: 100%; - } - - .system-chip { - display: none; - } - - .review-layout { +.review-layout { grid-template-columns: minmax(0, 1fr); } - - .review-layout > * { +.review-layout > * { min-width: 0; } - - .clip-list > * { +.clip-list > * { min-width: 0; } - - .clip-card { +.clip-card { grid-template-columns: minmax(0, 1fr); } - - .clip-card > * { +.clip-card > * { min-width: 0; max-width: 100%; } - - .preview-panel { +.preview-panel { position: static; max-height: none; overflow: visible; } } -@media (max-width: 860px) { - .app-shell { - display: block; - } - - .sidebar { - padding: 14px; - } - - .brand { - margin-bottom: 12px; - } - - .side-nav { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .side-nav a { - min-width: 0; - } - - .main-panel { - padding: 16px; - } - - .topbar { - margin: 0 0 18px; - } - - .topbar-actions { - flex-wrap: wrap; - } - - .page-heading h1 { - font-size: 26px; - } -} - @media (max-width: 640px) { - .sidebar { - position: sticky; - top: 0; - z-index: 50; - padding: 10px 12px; - border-right: 0; - border-bottom: 1px solid rgba(31, 45, 71, 0.08); - } - - .brand { - min-height: 48px; - margin-bottom: 8px; - } - - .brand-mark { - width: 34px; - height: 34px; - flex-basis: 34px; - } - - .side-nav { - display: flex; - grid-template-columns: none; - gap: 8px; - overflow-x: auto; - padding-bottom: 2px; - scrollbar-width: none; - } - - .side-nav::-webkit-scrollbar { - display: none; - } - - .side-nav a { - flex: 0 0 auto; - min-height: 36px; - padding: 0 10px; - } - - .sidebar-note { - display: none; - } - - .topbar-title, - .notice-dot, - .user-chip { - display: none; - } - - .topbar-actions { - align-items: stretch; - flex-direction: column; - } - - .panel, - .stat-card { - padding: 16px; - } - - .stat-card { - min-height: 104px; - } - - .page-heading h1 { - font-size: 24px; - } - - .button-row, - .review-row-actions, - .send-actions, - .publish-actions, - .source-monitor-actions { +.button-row, +.review-row-actions, +.send-actions, +.publish-actions, +.source-monitor-actions { align-items: stretch; flex-direction: column; } - - .topbar-actions .primary-button, - .primary-button, - .secondary-button, - .pagination button { - width: 100%; - } - - .icon-button { - width: 38px; - min-width: 38px; - } } -/* 发送中心:紧凑列表、批量栏与排期抽屉 */ .scheduler-health-card { display: flex; align-items: center; @@ -5195,7 +3334,6 @@ td a, border-radius: 16px; background: rgba(255, 255, 255, 0.74); } - .scheduler-health-card div { display: flex; align-items: center; gap: 8px; } .scheduler-health-card small { color: var(--muted); flex: 1 1 280px; } .worker-refresh-button { margin-left: auto; } @@ -5214,7 +3352,6 @@ td a, .worker-connection-help code { padding: 3px 7px; border-radius: 7px; color: #684200; background: rgba(255, 255, 255, 0.78); } .health-dot { width: 9px; height: 9px; border-radius: 50%; background: #f59e0b; } .health-dot.is-ok { background: #22c55e; box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.12); } - .publish-center-tabs { display: flex; gap: 8px; @@ -5224,7 +3361,6 @@ td a, border-radius: 14px; background: rgba(227, 235, 247, 0.72); } - .publish-center-tabs button { border: 0; border-radius: 10px; @@ -5233,12 +3369,10 @@ td a, background: transparent; cursor: pointer; } - .publish-center-tabs button.active { color: var(--blue); background: #fff; box-shadow: 0 6px 18px rgba(60, 90, 130, 0.12); } .publish-center-tabs span { margin-left: 6px; opacity: 0.7; } .publish-center-panel[hidden] { display: none; } .publish-compact-list { display: grid; gap: 10px; } - .publish-compact-row { display: grid; grid-template-columns: 28px 104px minmax(220px, 1fr) 170px auto; @@ -5249,14 +3383,14 @@ td a, border-radius: 16px; background: rgba(255, 255, 255, 0.82); } - .publish-row-thumb { width: 104px; height: 64px; border-radius: 10px; object-fit: cover; background: #111827; } .publish-row-main { display: grid; gap: 4px; min-width: 0; } -.publish-row-main > strong, .publish-row-main > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.publish-row-main > span, .publish-row-time small { color: var(--muted); font-size: 0.82rem; } +.publish-row-main > strong, +.publish-row-main > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.publish-row-main > span, +.publish-row-time small { color: var(--muted); font-size: 0.82rem; } .publish-row-time { display: grid; gap: 4px; } .publish-row-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } - .publish-inline-editor { grid-column: 2 / -1; display: grid; @@ -5266,13 +3400,11 @@ td a, border-radius: 14px; background: rgba(237, 244, 255, 0.7); } - .publish-inline-editor[hidden] { display: none; } .publish-inline-editor label { display: grid; gap: 6px; } .publish-inline-editor .span-2 { grid-column: 1 / -1; } .publish-inline-editor textarea { min-height: 92px; } .publish-advanced-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 12px; } - .publish-selection-bar { position: fixed; z-index: 80; @@ -5289,14 +3421,17 @@ td a, box-shadow: 0 18px 50px rgba(30, 55, 90, 0.2); backdrop-filter: blur(18px); } - -.publish-selection-bar[hidden], .schedule-drawer[hidden], .schedule-drawer-backdrop[hidden] { display: none; } +.publish-selection-bar[hidden], +.schedule-drawer[hidden], +.schedule-drawer-backdrop[hidden] { display: none; } .schedule-drawer-backdrop { position: fixed; z-index: 89; inset: 0; background: rgba(15, 23, 42, 0.22); } .schedule-drawer { position: fixed; z-index: 90; top: 0; right: 0; width: min(440px, 94vw); height: 100vh; padding: 24px; overflow-y: auto; background: #f9fbff; box-shadow: -20px 0 50px rgba(30, 55, 90, 0.2); } .schedule-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 22px; } .schedule-drawer form { display: grid; gap: 16px; } -.schedule-drawer form > label, .schedule-window-grid label { display: grid; gap: 7px; } -.schedule-start-field, .schedule-start-field > label { display: grid; gap: 7px; } +.schedule-drawer form > label, +.schedule-window-grid label { display: grid; gap: 7px; } +.schedule-start-field, +.schedule-start-field > label { display: grid; gap: 7px; } .schedule-after-latest-button { justify-self: start; margin-top: 2px; } .schedule-latest-note { margin: 2px 0 0; line-height: 1.55; } .schedule-latest-note.tone-red { color: var(--red); } @@ -5314,24 +3449,21 @@ td a, .publish-history-row small { grid-column: 3 / -1; } @media (max-width: 960px) { - .publish-compact-row { grid-template-columns: 28px 86px 1fr; } - .publish-row-thumb { width: 86px; } - .publish-row-time, .publish-row-actions { grid-column: 3; } - .publish-inline-editor { grid-column: 1 / -1; } - .publish-selection-bar { left: 50%; width: calc(100% - 28px); overflow-x: auto; } - .publish-history-row { grid-template-columns: 1fr 1fr; } +.publish-compact-row { grid-template-columns: 28px 86px 1fr; } +.publish-row-thumb { width: 86px; } +.publish-row-time, +.publish-row-actions { grid-column: 3; } +.publish-inline-editor { grid-column: 1 / -1; } +.publish-selection-bar { left: 50%; width: calc(100% - 28px); overflow-x: auto; } +.publish-history-row { grid-template-columns: 1fr 1fr; } } - -/* v2.1 发送中心:内容准备、排期计划、执行记录 */ .publish-content-list, .publish-plan-list, .publish-execution-list { display: grid; gap: 12px; } - .publish-content-list { gap: 16px; } - .publish-content-header-actions { display: flex; align-items: center; @@ -5339,7 +3471,6 @@ td a, gap: 10px; flex-wrap: wrap; } - .publish-content-maintenance { display: flex; align-items: center; @@ -5351,22 +3482,18 @@ td a, border-radius: 14px; background: rgba(247, 250, 255, 0.78); } - .publish-content-maintenance > div:first-child { display: grid; gap: 3px; } - .publish-content-maintenance small, .publish-copy-toolbar small { color: var(--muted); } - .publish-content-maintenance [data-backfill-covers][data-loading="true"] { cursor: wait; opacity: 0.72; } - .publish-task-group { display: grid; gap: 12px; @@ -5375,9 +3502,7 @@ td a, border-radius: 20px; background: rgba(247, 250, 255, 0.72); } - .publish-task-group[hidden] { display: none; } - .publish-task-group-header { display: flex; align-items: center; @@ -5385,7 +3510,6 @@ td a, gap: 20px; padding: 4px 4px 2px; } - .publish-task-group-identity { min-width: 0; } .publish-task-group-identity h3 { margin: 2px 0 6px; } .publish-task-group-identity p:not(.eyebrow) { margin: 0 0 5px; color: var(--muted); } @@ -5422,7 +3546,6 @@ td a, } .publish-task-group-body { display: grid; gap: 12px; } .publish-task-group-body[hidden] { display: none; } - .publish-content-card { display: grid; grid-template-columns: 28px 150px minmax(0, 1fr); @@ -5434,17 +3557,14 @@ td a, background: rgba(255, 255, 255, 0.84); transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; } - .publish-content-card.is-scheduled { border-color: rgba(31, 111, 255, 0.32); background: linear-gradient(135deg, rgba(244, 249, 255, 0.98), rgba(255, 255, 255, 0.90)); box-shadow: inset 3px 0 0 rgba(31, 111, 255, 0.72), 0 12px 30px rgba(31, 111, 255, 0.08); } - .publish-content-card[hidden], .publish-plan-row[hidden], .publish-execution-row[hidden] { display: none; } - .publish-content-media { display: grid; gap: 8px; } .publish-content-media video, .publish-content-media img { @@ -5467,7 +3587,6 @@ td a, flex-wrap: wrap; gap: 10px; } - .publish-copy-toolbar { display: flex; align-items: center; @@ -5478,12 +3597,10 @@ td a, border-radius: 12px; background: rgba(238, 245, 255, 0.7); } - .publish-copy-toolbar > div { display: grid; gap: 2px; } - .publish-schedule-badge { display: inline-flex; align-items: center; @@ -5513,8 +3630,6 @@ td a, background: var(--blue); box-shadow: 0 0 0 3px rgba(31, 111, 255, 0.12); } -.text-button.danger { color: var(--red); } - .publish-content-grid, .publish-bilibili-fields { display: grid; @@ -5537,7 +3652,6 @@ td a, } .publish-copy-label small { color: var(--muted); font-variant-numeric: tabular-nums; } .publish-content-form .button-row { margin-top: 14px; } - .publish-plan-header, .publish-plan-row { display: grid; @@ -5558,7 +3672,9 @@ td a, background: rgba(255, 255, 255, 0.82); } .publish-missed-alert { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; border-color: #e9ca82; background: #fff7e7; color: #765000; } -.publish-missed-alert[hidden], .publish-missed-note[hidden], [data-reschedule-job][hidden] { display: none; } +.publish-missed-alert[hidden], +.publish-missed-note[hidden], +[data-reschedule-job][hidden] { display: none; } .publish-missed-alert .secondary-button { margin-left: auto; } .publish-missed-note { flex: 1 0 100%; color: #8d6200; line-height: 1.4; } .publish-plan-row[data-status="WAITING"][data-error-code="schedule_missed"] { border: 2px solid #dba83e; background: #fff8e9; } @@ -5580,7 +3696,6 @@ td a, .publish-send-readiness.is-ready { color: #18794e; } .publish-send-readiness.is-blocked { color: #9a6400; } .publish-execution-row [data-repair-account-select] { min-width: 150px; } - .publish-platform-switch { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -5634,15 +3749,17 @@ td a, .platform-card-icon.douyin { background: linear-gradient(145deg, #222936, #0f172a); box-shadow: inset 3px 0 #24f1ff, inset -3px 0 #ff2d55; } .platform-card-icon.bilibili { background: linear-gradient(145deg, #24a9e8, #1689c6); } .platform-card-action { color: var(--blue); font-size: 0.84rem; } - .publish-calendar-card { margin-bottom: 20px; padding: 16px; border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 18px; background: rgba(250, 252, 255, 0.86); } -.publish-calendar-toolbar, .publish-plan-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.publish-calendar-toolbar, +.publish-plan-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .publish-calendar-toolbar { margin-bottom: 14px; } -.publish-calendar-toolbar h3, .publish-plan-section-heading h3 { margin: 2px 0 0; } +.publish-calendar-toolbar h3, +.publish-plan-section-heading h3 { margin: 2px 0 0; } .calendar-navigation { flex-wrap: nowrap; } .calendar-navigation .secondary-button { white-space: nowrap; } .publish-calendar-scroll { overflow-x: auto; padding-bottom: 2px; } -.publish-calendar-weekdays, .publish-calendar-grid { min-width: 700px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.publish-calendar-weekdays, +.publish-calendar-grid { min-width: 700px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } .publish-calendar-weekdays span { padding: 7px 8px; color: var(--muted); text-align: center; font-size: 0.78rem; } .publish-calendar-grid { overflow: hidden; border-top: 1px solid rgba(126, 151, 187, 0.18); border-left: 1px solid rgba(126, 151, 187, 0.18); border-radius: 12px; } .publish-calendar-day { min-height: 96px; padding: 8px; border-right: 1px solid rgba(126, 151, 187, 0.18); border-bottom: 1px solid rgba(126, 151, 187, 0.18); background: rgba(255, 255, 255, 0.82); transition: background 160ms ease, box-shadow 160ms ease; } @@ -5676,7 +3793,6 @@ td a, .publish-plan-empty { padding: 30px 18px; border: 1px dashed rgba(126, 151, 187, 0.28); border-radius: 14px; color: var(--muted); text-align: center; background: rgba(247, 250, 255, 0.7); } .publish-plan-empty[hidden] { display: none; } .publish-plan-row.is-calendar-focus { border-color: rgba(38, 118, 255, 0.62); box-shadow: 0 0 0 4px rgba(38, 118, 255, 0.1); } - .publish-execution-header, .publish-execution-row { display: grid; @@ -5699,7 +3815,6 @@ td a, .publish-error-detail { grid-column: 1 / -1; padding: 10px 12px; border-radius: 10px; background: rgba(255, 238, 238, 0.72); } .publish-error-detail p { margin: 8px 0; color: #9f2e2e; } .publish-error-detail code { word-break: break-word; } - .publish-history-heading { align-items: flex-start; gap: 18px; @@ -5858,7 +3973,6 @@ td a, gap: 14px; margin-top: 18px; } - .publish-account-list { display: grid; gap: 10px; } .publish-account-list article { display: grid; gap: 10px; padding: 14px; border: 1px solid rgba(126, 151, 187, 0.18); border-radius: 14px; background: #fff; } .publish-account-list article > div:first-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; } @@ -5867,81 +3981,88 @@ td a, .publish-selection-bar select { min-width: 110px; } @media (max-width: 1180px) { - .publish-platform-context { grid-template-columns: 1fr; } - .publish-plan-header { display: none; } - .publish-plan-row { grid-template-columns: 28px 132px minmax(190px, 1fr); align-items: start; } - .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 3; } - .publish-plan-row > :nth-child(2) { grid-column: 2; grid-row: 1; } - .publish-plan-row > :nth-child(3) { grid-column: 3; grid-row: 1; } - .publish-plan-row > :nth-child(4) { grid-column: 2; grid-row: 2; color: var(--muted); } - .publish-plan-row > :nth-child(5) { grid-column: 3; grid-row: 2; color: var(--muted); } - .publish-plan-row > :nth-child(6) { grid-column: 2; grid-row: 3; } - .publish-plan-row .publish-row-actions { grid-column: 3; grid-row: 3; justify-content: flex-start; } - .publish-plan-row .publish-send-readiness { text-align: left; } - .publish-execution-header { display: none; } - .publish-execution-row { grid-template-columns: 34px minmax(180px, 1fr) 110px 130px; align-items: start; } - .publish-execution-row .publish-history-select { grid-column: 1; grid-row: 1 / 5; } - .publish-execution-row .publish-history-identity { grid-column: 2 / -1; } - .publish-execution-row .publish-row-actions { grid-column: 2 / -1; justify-content: flex-start; } +.publish-platform-context { grid-template-columns: 1fr; } +.publish-plan-header { display: none; } +.publish-plan-row { grid-template-columns: 28px 132px minmax(190px, 1fr); align-items: start; } +.publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 3; } +.publish-plan-row > :nth-child(2) { grid-column: 2; grid-row: 1; } +.publish-plan-row > :nth-child(3) { grid-column: 3; grid-row: 1; } +.publish-plan-row > :nth-child(4) { grid-column: 2; grid-row: 2; color: var(--muted); } +.publish-plan-row > :nth-child(5) { grid-column: 3; grid-row: 2; color: var(--muted); } +.publish-plan-row > :nth-child(6) { grid-column: 2; grid-row: 3; } +.publish-plan-row .publish-row-actions { grid-column: 3; grid-row: 3; justify-content: flex-start; } +.publish-plan-row .publish-send-readiness { text-align: left; } +.publish-execution-header { display: none; } +.publish-execution-row { grid-template-columns: 34px minmax(180px, 1fr) 110px 130px; align-items: start; } +.publish-execution-row .publish-history-select { grid-column: 1; grid-row: 1 / 5; } +.publish-execution-row .publish-history-identity { grid-column: 2 / -1; } +.publish-execution-row .publish-row-actions { grid-column: 2 / -1; justify-content: flex-start; } } @media (max-width: 760px) { - .publish-content-maintenance, - .publish-copy-toolbar { +.publish-content-maintenance, +.publish-copy-toolbar { align-items: stretch; flex-direction: column; } - .publish-copy-toolbar .secondary-button { width: 100%; } - .publish-content-header-actions { +.publish-copy-toolbar .secondary-button { width: 100%; } +.publish-content-header-actions { width: 100%; justify-content: stretch; } - .publish-content-header-actions .secondary-button { width: 100%; } - .publish-platform-switch { grid-template-columns: 1fr; } - .publish-calendar-toolbar, .publish-plan-section-heading { align-items: flex-start; flex-direction: column; } - .publish-plan-section-heading > small { text-align: left; } - .calendar-navigation { width: 100%; } - .publish-calendar-day-detail > header { align-items: stretch; } - .publish-calendar-day-item { grid-template-columns: 56px minmax(0, 1fr); } - .publish-calendar-day-action { display: none; } - .publish-content-card { grid-template-columns: 28px 1fr; } - .publish-content-media { grid-column: 2; grid-template-columns: 1fr 1fr; } - .publish-content-media video, .publish-content-media img { width: 100%; } - .publish-content-form { grid-column: 1 / -1; } - .publish-content-grid, .publish-bilibili-fields { grid-template-columns: 1fr; } - .publish-content-grid .span-2, .publish-bilibili-fields.span-2, .publish-bilibili-fields .span-2 { grid-column: 1; } - .publish-card-heading { display: grid; } - .publish-task-group-header { align-items: flex-start; flex-direction: column; } - .publish-task-group-actions { +.publish-content-header-actions .secondary-button { width: 100%; } +.publish-platform-switch { grid-template-columns: 1fr; } +.publish-calendar-toolbar, +.publish-plan-section-heading { align-items: flex-start; flex-direction: column; } +.publish-plan-section-heading > small { text-align: left; } +.calendar-navigation { width: 100%; } +.publish-calendar-day-detail > header { align-items: stretch; } +.publish-calendar-day-item { grid-template-columns: 56px minmax(0, 1fr); } +.publish-calendar-day-action { display: none; } +.publish-content-card { grid-template-columns: 28px 1fr; } +.publish-content-media { grid-column: 2; grid-template-columns: 1fr 1fr; } +.publish-content-media video, +.publish-content-media img { width: 100%; } +.publish-content-form { grid-column: 1 / -1; } +.publish-content-grid, +.publish-bilibili-fields { grid-template-columns: 1fr; } +.publish-content-grid .span-2, +.publish-bilibili-fields.span-2, +.publish-bilibili-fields .span-2 { grid-column: 1; } +.publish-card-heading { display: grid; } +.publish-task-group-header { align-items: flex-start; flex-direction: column; } +.publish-task-group-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; gap: 8px; } - .publish-task-group-select { justify-self: start; } - .publish-task-group-actions [data-task-group-toggle] { grid-column: 1 / -1; width: 100%; } - .publish-task-group-identity p strong { max-width: 70vw; } - .publish-card-heading .publish-card-heading-actions { justify-content: flex-start; } - .publish-plan-row, .publish-execution-row { grid-template-columns: 28px minmax(0, 1fr); } - .publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 7; } - .publish-plan-row > :nth-child(n+2) { grid-column: 2; grid-row: auto; } - .publish-plan-video, .publish-plan-row .publish-row-actions, - .publish-execution-row .publish-history-identity, .publish-execution-row .publish-row-actions { grid-column: 2; } - .publish-plan-row .publish-plan-video, .publish-plan-row .publish-row-actions { grid-column: 2; } - .publish-send-readiness { text-align: left; } - .publish-history-heading, - .publish-history-toolbar, - .publish-history-toolbar-actions, - .publish-history-batch-bar { +.publish-task-group-select { justify-self: start; } +.publish-task-group-actions [data-task-group-toggle] { grid-column: 1 / -1; width: 100%; } +.publish-task-group-identity p strong { max-width: 70vw; } +.publish-card-heading .publish-card-heading-actions { justify-content: flex-start; } +.publish-plan-row, +.publish-execution-row { grid-template-columns: 28px minmax(0, 1fr); } +.publish-plan-row > :nth-child(1) { grid-column: 1; grid-row: 1 / 7; } +.publish-plan-row > :nth-child(n+2) { grid-column: 2; grid-row: auto; } +.publish-plan-video, +.publish-plan-row .publish-row-actions, +.publish-execution-row .publish-history-identity, +.publish-execution-row .publish-row-actions { grid-column: 2; } +.publish-plan-row .publish-plan-video, +.publish-plan-row .publish-row-actions { grid-column: 2; } +.publish-send-readiness { text-align: left; } +.publish-history-heading, +.publish-history-toolbar, +.publish-history-toolbar-actions, +.publish-history-batch-bar { align-items: stretch; flex-direction: column; } - .publish-history-view-switch { width: 100%; } - .publish-history-view-switch .secondary-button { flex: 1; } - .publish-history-toolbar-actions .compact-filter { width: 100%; } +.publish-history-view-switch { width: 100%; } +.publish-history-view-switch .secondary-button { flex: 1; } +.publish-history-toolbar-actions .compact-filter { width: 100%; } } - -/* ── Subtitle editor v2: 原片主时间轴 + 切片继承 ── */ .subtitle-editor { margin-bottom: 24px; overflow: hidden; @@ -6115,25 +4236,24 @@ td a, .subtitle-cue-empty { padding: 50px 20px; color: var(--muted); text-align: center; } @media (max-width: 1080px) { - .subtitle-editor-media-grid { grid-template-columns: 1fr; } - .subtitle-cue-row { grid-template-columns: 38px 100px 150px minmax(220px, 1fr); } - .subtitle-speaker { display: none; } +.subtitle-editor-media-grid { grid-template-columns: 1fr; } +.subtitle-cue-row { grid-template-columns: 38px 100px 150px minmax(220px, 1fr); } +.subtitle-speaker { display: none; } } @media (max-width: 760px) { - .subtitle-editor-heading, - .subtitle-editor-trackbar, - .subtitle-editor-state { align-items: stretch; flex-direction: column; } - .subtitle-editor-trackbar .button-row { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } - .subtitle-cue-row { +.subtitle-editor-heading, +.subtitle-editor-trackbar, +.subtitle-editor-state { align-items: stretch; flex-direction: column; } +.subtitle-editor-trackbar .button-row { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } +.subtitle-cue-row { grid-template-columns: 34px 96px minmax(185px, 1fr); grid-template-rows: 83px 31px; min-width: 560px; } - .subtitle-cue-time-inputs { display: none; } - .subtitle-cue-issues { grid-column: 2 / -1; } +.subtitle-cue-time-inputs { display: none; } +.subtitle-cue-issues { grid-column: 2 / -1; } } - .subtitle-review-gate { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -6183,22 +4303,19 @@ td a, .subtitle-ai-diff-arrow { color: var(--muted); } @media (max-width: 900px) { - .subtitle-review-gate { grid-template-columns: 1fr; } - .subtitle-ai-diff-row { grid-template-columns: auto 1fr; } - .subtitle-ai-diff-time, - .subtitle-ai-diff-before, - .subtitle-ai-diff-arrow, - .subtitle-ai-diff-after { grid-column: 2; } +.subtitle-review-gate { grid-template-columns: 1fr; } +.subtitle-ai-diff-row { grid-template-columns: auto 1fr; } +.subtitle-ai-diff-time, +.subtitle-ai-diff-before, +.subtitle-ai-diff-arrow, +.subtitle-ai-diff-after { grid-column: 2; } } - -/* Dashboard weekly overview */ .dashboard-overview-panel { position: relative; margin-bottom: 18px; overflow: hidden; padding: 0; } - .dashboard-overview-panel::before { position: absolute; top: -120px; @@ -6210,7 +4327,6 @@ td a, content: ""; pointer-events: none; } - .dashboard-overview-header { position: relative; z-index: 1; @@ -6220,18 +4336,15 @@ td a, gap: 20px; padding: 22px 24px 18px; } - .dashboard-overview-header h2 { margin: 0; color: #111827; } - .dashboard-overview-header > div > p:last-child { margin: 7px 0 0; color: var(--muted); font-size: 13px; } - .dashboard-week-chip { flex: 0 0 auto; padding: 8px 12px; @@ -6243,7 +4356,6 @@ td a, font-weight: 760; white-space: nowrap; } - .dashboard-stat-strip { position: relative; z-index: 1; @@ -6255,7 +4367,6 @@ td a, border-radius: 14px; background: rgba(248, 251, 255, 0.72); } - .dashboard-stat-item { position: relative; display: grid; @@ -6263,11 +4374,9 @@ td a, gap: 7px; padding: 18px 20px; } - .dashboard-stat-item + .dashboard-stat-item { border-left: 1px solid rgba(31, 45, 71, 0.08); } - .dashboard-stat-item::after { position: absolute; top: 18px; @@ -6279,19 +4388,16 @@ td a, box-shadow: 0 0 0 5px color-mix(in srgb, currentColor 10%, transparent); content: ""; } - .dashboard-stat-item > span { color: var(--muted); font-size: 12px; font-weight: 740; } - .dashboard-stat-item > strong { color: #111827; font-size: clamp(28px, 2.1vw, 36px); line-height: 1; } - .dashboard-stat-item > small { min-height: 34px; color: currentColor; @@ -6302,149 +4408,129 @@ td a, } @media (max-width: 1180px) { - .dashboard-stat-strip { +.dashboard-stat-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .dashboard-stat-item:nth-child(3) { +.dashboard-stat-item:nth-child(3) { border-left: 0; } - - .dashboard-stat-item:nth-child(n + 3) { +.dashboard-stat-item:nth-child(n + 3) { border-top: 1px solid rgba(31, 45, 71, 0.08); } } @media (max-width: 640px) { - .dashboard-overview-header { +.dashboard-overview-header { align-items: stretch; flex-direction: column; } - - .dashboard-overview-header { +.dashboard-overview-header { padding: 18px 16px 14px; } - - .dashboard-week-chip { +.dashboard-week-chip { align-self: flex-start; } - - .dashboard-stat-strip { +.dashboard-stat-strip { margin: 0 16px 20px; } - - .dashboard-stat-item { +.dashboard-stat-item { padding: 16px; } } - -/* Motion system: page-specific, local-only and safe to ignore when JS is unavailable. */ body.motion-enabled { --motion-duration: 420ms; --motion-fast: 260ms; --motion-ease: cubic-bezier(0.22, 1, 0.36, 1); } - .motion-enabled .motion-pending { opacity: 0; will-change: opacity, transform; } - .motion-enabled .motion-pending.motion-rise { transform: translate3d(0, 10px, 0); } - .motion-enabled .motion-pending.motion-soft { transform: translate3d(0, 4px, 0); } - .motion-enabled .motion-pending.motion-in { animation: motion-rise-in var(--motion-duration) var(--motion-ease) both; animation-delay: calc(min(var(--motion-order, 0), 9) * 36ms); } - .motion-enabled .motion-pending.motion-soft.motion-in { animation-name: motion-soft-in; animation-duration: 360ms; } - -.motion-enabled :is(.progress, .large-progress) > span, +.motion-enabled :is(.progress, +.large-progress) > span, .motion-enabled [data-task-live-progress-bar], .motion-enabled #cut-job-progress-bar, .motion-enabled #subtitle-batch-progress-bar { transition: width 360ms var(--motion-ease); } - -.motion-enabled :is(.metro-step, .status-pill, .publish-calendar-day, .publish-plan-row, .publish-content-card) { +.motion-enabled :is(.metro-step, +.publish-calendar-day, +.publish-plan-row, +.publish-content-card) { transition: color var(--motion-fast) ease, background-color var(--motion-fast) ease, border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; } - -.motion-enabled :is(.primary-button, .secondary-button, .icon-button, .text-button, .link-button):not(:disabled):active { +.motion-enabled :is(.primary-button, +.text-button, +.link-button):not(:disabled):active { transform: translateY(0) scale(0.985); } - .motion-enabled .motion-data-updated { animation: motion-data-highlight 620ms ease-out both; } - .motion-enabled .motion-item-added { animation: motion-item-added 360ms var(--motion-ease) both; animation-delay: calc(min(var(--motion-order, 0), 7) * 28ms); } - .motion-enabled .motion-confirm { animation: motion-confirm 480ms var(--motion-ease) both; } - .motion-enabled .motion-panel-activated, .motion-enabled.motion-settled .publish-calendar-day-detail:not([hidden]), .motion-enabled.motion-settled .subtitle-batch-job:not([hidden]) { animation: motion-panel-swap 300ms var(--motion-ease) both; } - .motion-enabled .clip-card.is-removing { transition: opacity 180ms ease, transform 180ms ease; transform: scale(0.995); } @media (hover: hover) { - .motion-enabled :is(.review-task-row, table tbody tr) { +.motion-enabled :is(.review-task-row, +table tbody tr) { transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } - - .motion-enabled .review-task-row:hover { +.motion-enabled .review-task-row:hover { border-color: rgba(37, 111, 255, 0.2); box-shadow: 0 10px 28px rgba(24, 39, 75, 0.07); transform: translateY(-1px); } - - .motion-enabled table tbody tr:hover { +.motion-enabled table tbody tr:hover { background: rgba(234, 242, 255, 0.38); } } - .motion-enabled.motion-settled .modal-backdrop:not([hidden]), .motion-enabled.motion-settled .source-monitor-backdrop:not([hidden]), .motion-enabled.motion-settled .schedule-drawer-backdrop:not([hidden]) { animation: motion-overlay-in 220ms ease-out both; } - .motion-enabled.motion-settled .modal-backdrop:not([hidden]) > .modal-panel, .motion-enabled.motion-settled .source-monitor-backdrop:not([hidden]) > .source-monitor-panel { animation: motion-dialog-in 300ms var(--motion-ease) both; } - .motion-enabled.motion-settled .transcript-drawer:not([hidden]), .motion-enabled.motion-settled .schedule-drawer:not([hidden]) { animation: motion-drawer-in 320ms var(--motion-ease) both; } - .motion-enabled.motion-settled details[open] > :not(summary) { animation: motion-soft-in 240ms ease-out both; } @@ -6495,26 +4581,22 @@ body.motion-enabled { } @media print, (prefers-reduced-motion: reduce) { - html:focus-within { +html:focus-within { scroll-behavior: auto; } - - *, - *::before, - *::after { +*, +*::before, +*::after { scroll-behavior: auto !important; animation-duration: 1ms !important; animation-delay: 0ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; } - - .send-publishing-spinner { +.send-publishing-spinner { animation: none !important; } } - -/* 数据动态排期:隐藏无效输入,选择操作保持可读。 */ .schedule-drawer [hidden] { display: none !important; } [data-adaptive-fields] { display: grid; gap: 14px; } [data-adaptive-fields] label { display: grid; gap: 7px; } @@ -6524,5 +4606,5 @@ body.motion-enabled { [data-adaptive-panel] summary { cursor: pointer; font-weight: 650; } [data-adaptive-panel] { overflow-wrap: anywhere; } @media (max-width: 900px) { - .publish-selection-bar { width: calc(100% - 28px); } +.publish-selection-bar { width: calc(100% - 28px); } } diff --git a/app/static/js/app.js b/app/static/js/app.js index 7124ad6..b1b8b4e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,2586 +1,32 @@ -// Shared helpers are initialized by base.html, independently of this bundle. - -const newTaskForm = document.querySelector("#new-task-form"); -const newTaskAutoMode = newTaskForm?.querySelector("input[name='auto_mode']"); -const newTaskSubmitButton = document.querySelector("#new-task-submit-button"); -const selectionProfileInput = document.querySelector("#selection-profile"); -const longLiveSettings = document.querySelector("#long-live-settings"); - -function updateNewTaskSubmitLabel() { - if (!newTaskSubmitButton) return; - newTaskSubmitButton.textContent = newTaskAutoMode?.checked ? "创建并自动处理" : "创建任务"; -} - -if (newTaskAutoMode) { - newTaskAutoMode.addEventListener("change", updateNewTaskSubmitLabel); - updateNewTaskSubmitLabel(); -} - -if (selectionProfileInput && longLiveSettings) { - const profiles = JSON.parse(document.querySelector("#content-profile-options")?.textContent || "[]"); - const updateLongLiveSettings = () => { - longLiveSettings.hidden = selectionProfileInput.value !== "long_live_talk"; - const profile = profiles.find(item => item.id === selectionProfileInput.value); - if (!profile) return; - const promptInput = document.querySelector("#new-task-prompt"); - if (promptInput) promptInput.value = newTaskForm.dataset.trialPreset || profile.prompt_preset_id; - const durationInput = newTaskForm.querySelector('[name="max_clip_duration"]'); - const poolInput = newTaskForm.querySelector('[name="candidate_clip_count"]'); - const targetInput = newTaskForm.querySelector('[name="final_clip_target"]'); - const isContent = profile.analyzer_key === "content"; - durationInput.max = isContent ? String(Math.floor(profile.duration.max_seconds / 60)) : "60"; - durationInput.value = isContent ? durationInput.max : "10"; - for (const option of poolInput.options) option.disabled = Number(option.value) > profile.selection.candidate_pool_max; - poolInput.value = String(profile.selection.candidate_pool_default); - if (!poolInput.value) poolInput.value = "12"; - targetInput.value = String(Math.min(12, profile.selection.final_target_default)); - const trialNotice = ["interview_story", "knowledge_opinion"].includes(profile.id) - ? "试用模板:真实内容质量待日常使用验证,请审核后再使用成片。" : ""; - document.querySelector("#selection-profile-hint").textContent = `${trialNotice}${profile.description} 推荐场景:${profile.recommended_scenes.join("、")}。`; - const lo = profile.duration.recommended_min_seconds; - const hi = profile.duration.recommended_max_seconds; - document.querySelector("#profile-duration-hint").textContent = lo - ? `推荐 ${lo}–${hi} 秒;模板硬边界 ${profile.duration.min_seconds}–${profile.duration.max_seconds} 秒。` - : "通用模式按任务时长上限选片,保留完整表达。"; - }; - selectionProfileInput.addEventListener("change", updateLongLiveSettings); - updateLongLiveSettings(); -} - -if (newTaskForm) { - newTaskForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const result = document.querySelector("#new-task-result"); - const submitButton = newTaskForm.querySelector("button[type='submit']"); - const formData = new FormData(newTaskForm); - const payload = Object.fromEntries(formData.entries()); - const videoFileInput = document.querySelector("#video-file-input"); - - submitButton.disabled = true; - result.textContent = "正在创建任务..."; - - try { - if (!payload.selection_profile) throw new Error("请选择选片模式"); - if (!videoFileInput.files.length) throw new Error("请选择要上传的视频文件"); - const uploadData = new FormData(); - for (const key of [ - "task_name", "platform", "max_clip_duration", "candidate_clip_count", - "selection_profile", "final_clip_target", "ai_prompt_preset_id", "ai_provider", - "subtitle_strategy", - ]) uploadData.append(key, payload[key] || ""); - if (payload.challenger_id) { - uploadData.append("challenger_id", payload.challenger_id); - uploadData.append("challenger_sha256", payload.challenger_sha256 || ""); - uploadData.append("confirm_challenger", payload.confirm_challenger === "true" ? "true" : "false"); - } - if (payload.selection_profile === "long_live_talk") { - uploadData.append("highlight_density_per_hour", payload.highlight_density_per_hour || "4"); - uploadData.append("highlight_total_limit", payload.highlight_total_limit || "30"); - } - uploadData.append("ai_preference", ""); - uploadData.append("auto_mode", payload.auto_mode === "true" ? "true" : "false"); - uploadData.append("visual_enabled", payload.visual_enabled === "true" ? "true" : "false"); - uploadData.append("auto_metadata_use_ai", "false"); - uploadData.append("video_file", videoFileInput.files[0]); - const data = await apiFetch("/api/tasks/upload", { method: "POST", body: uploadData }); - result.textContent = `${data.message}${payload.auto_mode === "true" ? " 全自动流水线已启动。" : ""} 正在进入详情页...`; - window.location.href = data.detail_url; - } catch (error) { - result.textContent = `任务创建失败:${error.message}`; - } finally { - submitButton.disabled = false; - } - }); -} - -const videoFileInput = document.querySelector("#video-file-input"); -const videoFileName = document.querySelector("#video-file-name"); - -if (videoFileInput && videoFileName) { - videoFileInput.addEventListener("change", () => { - const file = videoFileInput.files[0]; - videoFileName.textContent = file ? `已选择:${file.name}` : "尚未选择视频文件。"; - }); -} - -async function handleProcessAction(button) { - if (button.dataset.confirm && !window.confirm(button.dataset.confirm)) { - return; - } - const result = document.querySelector("#process-result"); - const originalText = button.textContent; - button.disabled = true; - button.textContent = "处理中..."; - if (result) result.textContent = "正在执行,请稍等..."; - - try { - let endpoint = button.dataset.endpoint; - let data; - try { - data = await apiFetch(endpoint, { method: "POST" }); - } catch (error) { - const needsAIConfirmation = error.status === 409 - && error.details?.code === "ai_retry_confirmation_required"; - if (!needsAIConfirmation) throw error; - const confirmed = window.confirm( - `${error.details.message}\n\n这一步可能产生新的 AI 调用费用,是否确认继续?`, - ); - if (!confirmed) { - if (result) result.textContent = "已取消 AI 重试,原失败 Job 和证据保持不变。"; - return; - } - const separator = endpoint.includes("?") ? "&" : "?"; - endpoint = `${endpoint}${separator}confirm_uncertain_ai=true`; - if (result) result.textContent = "已确认,正在安全创建或恢复 AI 分析 Job..."; - data = await apiFetch(endpoint, { method: "POST" }); - } - if (result) result.textContent = data.message || "处理完成,正在刷新页面..."; - if (button.dataset.endpoint.includes("/process/transcript")) { - startTranscriptPolling(true); - startTaskLiveStatusPolling(true); - if (data.status === "completed") { - window.setTimeout(() => window.location.reload(), 600); - } - return; - } - if (button.dataset.endpoint.includes("/process/auto-")) { - if (result) result.textContent = data.message || "全自动流程已启动,状态会在当前页面自动更新。"; - startTaskLiveStatusPolling(true); - return; - } - window.location.reload(); - } catch (error) { - if (result) result.textContent = `处理失败:${error.message}`; - } finally { - button.disabled = false; - button.textContent = originalText; - } -} - -function bindProcessActionButton(button) { - if (!button || button.dataset.processActionBound === "true") return; - button.dataset.processActionBound = "true"; - button.addEventListener("click", () => handleProcessAction(button)); -} - -document.querySelectorAll(".js-process-action").forEach((button) => { - bindProcessActionButton(button); -}); - -const transcriptPanel = document.querySelector("#transcript-panel"); -const transcriptProgress = document.querySelector("#transcript-progress"); -const transcriptProgressMessage = document.querySelector("#transcript-progress-message"); -const transcriptProgressPercent = document.querySelector("#transcript-progress-percent"); -const transcriptProgressBar = document.querySelector("#transcript-progress-bar"); -const transcriptProgressDetail = document.querySelector("#transcript-progress-detail"); -const transcriptProgressRuntime = document.querySelector("#transcript-progress-runtime"); -const transcriptPreviewBox = document.querySelector("#transcript-preview-box"); -const cancelTranscriptButtons = document.querySelectorAll(".js-cancel-transcript"); -const localTranscriptButton = document.querySelector("#local-transcript-button"); -let transcriptPollingTimer = null; -let transcriptPollingStartedFromRunning = false; - -function renderTranscriptPreview(preview, transcriptExists) { - if (!transcriptPreviewBox) return; - transcriptPreviewBox.replaceChildren(); - if (preview.length) { - preview.forEach((line) => { - const paragraph = document.createElement("p"); - const time = document.createElement("time"); - time.textContent = line.time; - paragraph.append(time, document.createTextNode(line.text)); - transcriptPreviewBox.append(paragraph); - }); - return; - } - - const empty = document.createElement("p"); - empty.className = "empty-note"; - empty.textContent = transcriptExists - ? "已生成转写文件,但当前文件里还没有真实语音转写内容。请查看上方进度或日志。" - : "尚未生成转写文件。请先点击“提取音频”,再点击“生成转写 MD”。"; - transcriptPreviewBox.append(empty); -} - -function renderTranscriptStatus(data) { - if (!transcriptProgress || !data.progress || !Object.keys(data.progress).length) return; - const progress = data.progress; - const percent = Number(progress.percent || 0); - transcriptProgress.hidden = false; - transcriptProgress.dataset.status = progress.status || "running"; - transcriptProgressMessage.textContent = progress.message || "转写进度"; - transcriptProgressPercent.textContent = `${percent}%`; - transcriptProgressBar.style.width = `${percent}%`; - - if (progress.total_chunks) { - transcriptProgressDetail.textContent = `转写进度:${progress.current_chunk || 0}/${progress.total_chunks},约 ${percent}%`; - } else { - transcriptProgressDetail.textContent = "转写进度:正在准备分段"; - } - - const runtimeParts = [progress.provider_label, progress.model, progress.device, progress.compute_type].filter(Boolean); - transcriptProgressRuntime.textContent = runtimeParts.length - ? `当前转写配置:${runtimeParts.join(" / ")}` - : ""; - - updateCancelTranscriptButtons(progress.status || data.task_status); - renderTranscriptPreview(data.preview || [], data.transcript_exists); - updateWorkflowButtons(data); -} - -async function pollTranscriptStatus() { - if (!transcriptPanel) return; - const taskId = transcriptPanel.dataset.taskId; - const response = await fetch(`/api/tasks/${taskId}/transcript-status`); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "读取转写进度失败"); - } - renderTranscriptStatus(data); - const status = data.progress?.status; - if (status === "running" || status === "cancelling") { - transcriptPollingStartedFromRunning = true; - transcriptPollingTimer = window.setTimeout(pollTranscriptStatus, 5000); - } else if (status === "completed") { - transcriptPollingTimer = null; - transcriptPollingStartedFromRunning = false; - } else { - transcriptPollingTimer = null; - } -} - -function startTranscriptPolling(runImmediately = false) { - if (!transcriptPanel) return; - if (transcriptPollingTimer) { - window.clearTimeout(transcriptPollingTimer); - transcriptPollingTimer = null; - } - transcriptPollingStartedFromRunning = true; - if (runImmediately) { - pollTranscriptStatus().catch(() => {}); - return; - } - transcriptPollingTimer = window.setTimeout(pollTranscriptStatus, 5000); -} - -if (transcriptPanel) { - pollTranscriptStatus().catch(() => {}); -} - -function updateWorkflowButtons(data) { - const startButton = document.querySelector("#start-workflow-button"); - if (!startButton) return; - const progressStatus = data.progress?.status || ""; - const canRetryWithLocal = Boolean(data.local_retry_available); - const retryLabel = data.offline_only ? "重新本地转写" : "重新远程转写"; - if (localTranscriptButton) { - localTranscriptButton.hidden = !canRetryWithLocal; - } - if (data.transcript_exists) { - startButton.textContent = "转写已完成"; - startButton.disabled = true; - if (localTranscriptButton) localTranscriptButton.hidden = true; - return; - } - if (progressStatus === "running" || data.task_status === "transcribing") { - startButton.textContent = "转写处理中"; - startButton.disabled = true; - if (localTranscriptButton) localTranscriptButton.hidden = true; - updateCancelTranscriptButtons(progressStatus || "running"); - return; - } - if (progressStatus === "failed") { - startButton.textContent = retryLabel; - startButton.disabled = false; - startButton.classList.add("js-process-action"); - startButton.dataset.endpoint = `/api/tasks/${transcriptPanel?.dataset.taskId}/process/transcript-workflow?force=true`; - bindProcessActionButton(startButton); - updateCancelTranscriptButtons("failed"); - return; - } - if (progressStatus === "cancelled" || progressStatus === "stale") { - startButton.textContent = progressStatus === "stale" ? retryLabel : "重新生成转写"; - startButton.disabled = false; - startButton.classList.add("js-process-action"); - startButton.dataset.endpoint = `/api/tasks/${transcriptPanel?.dataset.taskId}/process/transcript-workflow?force=true`; - bindProcessActionButton(startButton); - updateCancelTranscriptButtons(progressStatus); - } -} - -function updateCancelTranscriptButtons(status) { - const shouldShow = status === "running" || status === "cancelling" || status === "transcribing"; - cancelTranscriptButtons.forEach((button) => { - button.hidden = !shouldShow; - button.disabled = status === "cancelling"; - button.textContent = status === "cancelling" ? "正在停止..." : "停止转写"; - }); -} - -cancelTranscriptButtons.forEach((button) => { - button.addEventListener("click", async () => { - if (!window.confirm("停止后不会删除已生成的旧转写文件。确认停止当前转写吗?")) { - return; - } - const result = document.querySelector("#process-result"); - const taskId = button.dataset.taskId || transcriptPanel?.dataset.taskId; - cancelTranscriptButtons.forEach((item) => { - item.disabled = true; - item.textContent = "正在停止..."; - }); - if (result) result.textContent = "正在请求停止转写..."; - - try { - const response = await fetch(`/api/tasks/${taskId}/process/transcript-cancel`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "停止转写失败"); - } - if (result) result.textContent = data.message || "已请求停止转写。"; - startTranscriptPolling(true); - } catch (error) { - if (result) result.textContent = `停止转写失败:${error.message}`; - updateCancelTranscriptButtons("running"); - } - }); -}); - -const aiAnalysisForm = document.querySelector("#ai-analysis-form"); -const aiAnalysisProvider = document.querySelector("#ai-analysis-provider"); -const saveAiPromptsButton = document.querySelector("#save-ai-prompts-button"); -const aiProcessResult = document.querySelector("#ai-process-result"); -const aiAnalysisSummary = document.querySelector("#ai-analysis-summary"); -const aiCandidateCountPill = document.querySelector("#ai-candidate-count-pill"); -const aiCandidateCountInput = document.querySelector("#ai-candidate-count-input"); -const aiSelectionProfile = aiAnalysisForm - ? aiAnalysisForm.dataset.selectionProfile || "general" - : "general"; -const aiFinalClipTarget = document.querySelector("#ai-final-clip-target"); -const aiHighlightDensity = document.querySelector("#ai-highlight-density"); -const aiHighlightTotalLimit = document.querySelector("#ai-highlight-total-limit"); -const showAiHistoryButton = document.querySelector("#show-ai-history-button"); -const refreshAiHistoryButton = document.querySelector("#refresh-ai-history-button"); -const aiAnalysisHistory = document.querySelector("#ai-analysis-history"); -const aiAnalysisHistoryList = document.querySelector("#ai-analysis-history-list"); -const aiAnalysisProgress = document.querySelector("#ai-analysis-progress"); -const aiAnalysisProgressMessage = document.querySelector("#ai-analysis-progress-message"); -const aiAnalysisProgressPercent = document.querySelector("#ai-analysis-progress-percent"); -const aiAnalysisProgressBar = document.querySelector("#ai-analysis-progress-bar"); -const runtimeLogState = document.querySelector("#runtime-log-state"); -const runtimeLogLines = document.querySelector("#runtime-log-lines"); -const aiProcessButtons = Array.from(document.querySelectorAll(".js-ai-process-action")); -const aiAnalysisControls = Array.from(new Set([ - ...(aiAnalysisForm ? aiAnalysisForm.querySelectorAll("button, input, textarea, select") : []), - ...aiProcessButtons, - saveAiPromptsButton, - aiCandidateCountInput, - aiFinalClipTarget, -].filter(Boolean))); -const autoPipelineMonitor = document.querySelector("[data-auto-pipeline-monitor]"); -const taskLiveOverview = document.querySelector("[data-task-live-overview]"); -const taskLiveProgressBar = document.querySelector("[data-task-live-progress-bar]"); -const taskLiveProgressNumber = document.querySelector("[data-task-live-progress-number]"); -const taskLiveNote = document.querySelector("[data-task-live-note]"); -const taskLiveUpdatedAt = document.querySelector("[data-task-live-updated-at]"); -const taskLiveCandidateCount = document.querySelector("[data-task-live-candidate-count]"); -const taskLiveOutputCount = document.querySelector("[data-task-live-output-count]"); -const taskLiveActions = document.querySelector("[data-live-task-actions]"); -const taskLiveOperation = document.querySelector("[data-task-live-operation]"); -const taskLiveOperationLabel = document.querySelector("[data-task-live-operation-label]"); -const taskLiveOperationProgress = document.querySelector("[data-task-live-operation-progress]"); -const taskLiveOperationMessage = document.querySelector("[data-task-live-operation-message]"); -let aiStatusPollingTimer = null; -let isAiAnalysisBusy = false; -let aiAnalysisControlStates = new Map(); -let taskLiveStatusTimer = null; -let taskLiveForcedPollingUntil = 0; -let taskLiveStatusRequestInFlight = false; -const TASK_LIVE_STATUS_INTERVAL_MS = 3000; - -function setAiAnalysisControlsDisabled(disabled) { - if (disabled) { - aiAnalysisControlStates = new Map( - aiAnalysisControls.map((control) => [control, control.disabled]), - ); - aiAnalysisControls.forEach((control) => { control.disabled = true; }); - return; - } - aiAnalysisControls.forEach((control) => { - control.disabled = aiAnalysisControlStates.get(control) ?? false; - }); - aiAnalysisControlStates.clear(); -} - -function summarizeErrorMessage(message, maxLength = 220) { - const text = String(message || "").replace(/\s+/g, " ").trim(); - if (!text) return "操作失败,请查看任务日志。"; - if (text.length <= maxLength) return text; - return `${text.slice(0, maxLength)}...(详细原因请查看任务日志)`; -} -let aiAnalysisRuns = []; - -function readJsonScript(id, fallback) { - const node = document.querySelector(`#${id}`); - if (!node?.textContent?.trim()) return fallback; - try { - return JSON.parse(node.textContent); - } catch { - return fallback; - } -} - -function getSelectedPromptPresetCard() { - if (!aiAnalysisForm) return null; - const selected = aiAnalysisForm.querySelector("input[name='ai_prompt_preset_id']:checked"); - if (!selected) return null; - return aiAnalysisForm.querySelector(`[data-prompt-preset-card][data-preset-id='${selected.value}']`); -} - -function updatePromptPresetCards() { - const selected = aiAnalysisForm?.querySelector("input[name='ai_prompt_preset_id']:checked"); - const selectedPresetId = selected?.value || ""; - document.querySelectorAll("[data-prompt-preset-tab]").forEach((tab) => { - const radio = tab.querySelector("input[name='ai_prompt_preset_id']"); - const isActive = radio?.value === selectedPresetId; - tab.classList.toggle("active", isActive); - tab.setAttribute("aria-selected", isActive ? "true" : "false"); - }); - document.querySelectorAll("[data-prompt-preset-card]").forEach((card) => { - const isActive = card.dataset.presetId === selectedPresetId; - card.classList.toggle("active", isActive); - card.hidden = !isActive; - }); -} - -function formatClipDuration(seconds) { - const totalSeconds = Number(seconds) || 0; - const minutes = Math.floor(totalSeconds / 60); - const restSeconds = totalSeconds % 60; - if (minutes <= 0) return `${restSeconds} 秒`; - return `${minutes} 分 ${String(restSeconds).padStart(2, "0")} 秒`; -} - -function analysisPromptSource(data) { - const slot = /^preset_0*(\d+)$/.exec(data.ai_prompt_preset_id || "")?.[1]; - const name = data.ai_prompt_preset_name || "来源未记录"; - const revision = data.prompt_version_number ? `第 ${data.prompt_version_number} 次` : "未记录"; - return `提示词方案:${slot ? `${slot} 号 · ` : ""}${name} · 内容修订:${revision}`; -} - -function renderAiAnalysisSummary(data) { - if (!aiAnalysisSummary) return; - const clips = Array.isArray(data.clips) && data.clips.length ? data.clips : (data.clip_summaries || []); - aiAnalysisSummary.hidden = false; - aiAnalysisSummary.replaceChildren(); - - const header = document.createElement("div"); - header.className = "ai-analysis-result-header"; - const titleWrap = document.createElement("div"); - const eyebrow = document.createElement("p"); - eyebrow.className = "eyebrow"; - eyebrow.textContent = "Analysis Result"; - const title = document.createElement("h3"); - title.textContent = data.run_number ? `${data.title || `第 ${data.run_number} 次分析`} · AI 选取结果` : "本次 AI 选取结果"; - const promptSource = document.createElement("p"); - promptSource.className = "form-hint"; - promptSource.textContent = analysisPromptSource(data); - titleWrap.append(eyebrow, title, promptSource); - const source = document.createElement("span"); - source.className = "status-pill"; - source.textContent = data.provider_label && data.model ? `${data.provider_label} · 模型 ${data.model}` : "AI 分析完成"; - header.append(titleWrap, source); - - const message = document.createElement("p"); - message.className = "ai-analysis-result-message"; - message.textContent = data.failure_message || data.fallback_notice || data.analysis_summary || data.message || "AI 分析已完成。"; - - const meta = document.createElement("div"); - meta.className = "ai-analysis-result-meta"; - const count = document.createElement("strong"); - count.textContent = `${clips.length} 条候选片段`; - const provider = document.createElement("span"); - provider.textContent = data.fallback_notice ? "远程失败后已自动改用本地 AI" : `目标 ${data.requested_clip_count || clips.length || 0} 条`; - meta.append(count, provider); - - const list = document.createElement("div"); - list.className = "ai-analysis-result-list"; - clips.forEach((clip, index) => { - const item = document.createElement("article"); - item.className = "ai-analysis-result-item"; - const itemTitle = document.createElement("strong"); - itemTitle.textContent = `${String(index + 1).padStart(2, "0")} · ${clip.title || "未命名片段"}`; - const itemMeta = document.createElement("span"); - itemMeta.textContent = `视频长度 ${formatClipDuration(clip.duration_seconds)} · ${clip.start_time || "--"} - ${clip.end_time || "--"}`; - item.append(itemTitle, itemMeta); - list.append(item); - }); - - const actions = document.createElement("div"); - actions.className = "button-row align-end"; - const reviewLink = document.createElement("a"); - reviewLink.className = "primary-button"; - reviewLink.href = data.review_url || `/tasks/${aiAnalysisForm?.dataset.taskId || ""}/clips/review`; - reviewLink.textContent = data.analysis_incomplete ? "查看已保留的候选(待补齐分析)" : "去检查并生成切片"; - actions.append(reviewLink); - - aiAnalysisSummary.append(header, message, meta, list, actions); -} - -function renderAiAnalysisHistory(runs) { - if (!aiAnalysisHistoryList) return; - aiAnalysisRuns = Array.isArray(runs) ? runs : []; - aiAnalysisHistoryList.replaceChildren(); - - if (!aiAnalysisRuns.length) { - const empty = document.createElement("p"); - empty.className = "empty-note"; - empty.textContent = "还没有历史分析结果。完成一次 AI 分析后,这里会自动出现记录。"; - aiAnalysisHistoryList.append(empty); - return; - } - - aiAnalysisRuns.forEach((run) => { - const item = document.createElement("article"); - item.className = "ai-history-item"; - const main = document.createElement("div"); - const title = document.createElement("strong"); - title.textContent = `${run.title || `第 ${run.run_number} 次分析`} · ${run.clip_count || 0} 条`; - const meta = document.createElement("span"); - meta.textContent = `${run.provider_label || "AI"} · ${run.model || "未知模型"} · ${analysisPromptSource(run)} · ${run.created_at || "未知时间"}`; - const summary = document.createElement("p"); - summary.textContent = run.failure_message || run.fallback_notice || run.analysis_summary || "暂无整体总结。"; - main.append(title, meta, summary); - if (run.challenger?.id) { - const trialLink = document.createElement("a"); - trialLink.href = `/api/content-review/challengers/${encodeURIComponent(run.challenger.id)}`; - trialLink.textContent = "Challenger 试验 · 查看来源版本"; - main.append(trialLink); - } - const visual = run.visual_signal || run.analysis_meta?.visual_signal; - if (visual) { - const link = document.createElement("a"); - link.href = `/tasks/${encodeURIComponent(aiAnalysisForm.dataset.taskId)}/visual-evidence?run_id=${encodeURIComponent(run.id)}`; - const labels = { disabled: "关闭", completed: "完成", partial: "部分可用", unavailable: "不可用" }; - link.textContent = `视觉 ${labels[visual.status] || visual.status} · ${visual.verified_count || 0}/${visual.candidate_count || 0} 候选 · 查看证据`; - main.append(link); - } - - const restoreButton = document.createElement("button"); - restoreButton.className = "secondary-button compact-button"; - restoreButton.type = "button"; - restoreButton.dataset.restoreRunId = run.id; - restoreButton.textContent = "恢复这次结果"; - item.append(main, restoreButton); - aiAnalysisHistoryList.append(item); - }); -} - -async function refreshAiAnalysisHistory() { - if (!aiAnalysisForm) return; - const taskId = aiAnalysisForm.dataset.taskId; - const response = await fetch(`/api/tasks/${taskId}/ai-analysis-runs`); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "读取历史分析失败"); - } - renderAiAnalysisHistory(data.runs || []); - if (data.latest) { - renderAiAnalysisSummary(data.latest); - } -} - -async function saveTaskCandidateClipCount() { - if (!aiAnalysisForm || !aiCandidateCountInput) return null; - const taskId = aiAnalysisForm.dataset.taskId; - const count = Number(aiCandidateCountInput.value || 12); - if (!Number.isInteger(count) || count < 1 || count > 50) { - throw new Error("候选片段数量必须是 1 到 50 之间的整数。"); - } - const response = await fetch(`/api/tasks/${taskId}/candidate-clip-count`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ candidate_clip_count: count }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "候选片段数量保存失败"); - } - return data; -} - -async function saveTaskAiPromptSettings() { - if (!aiAnalysisForm) return null; - if (aiAnalysisForm.dataset.challengerId) return {message: "Challenger 的 Profile 与 Prompt 已冻结;已保存其他分析设置。"}; - const taskId = aiAnalysisForm.dataset.taskId; - const selected = aiAnalysisForm.querySelector("input[name='ai_prompt_preset_id']:checked"); - if (!selected) { - throw new Error("请选择一个 AI Prompt 方案"); - } - - const cards = Array.from(aiAnalysisForm.querySelectorAll("[data-prompt-preset-card]")) - .filter(card => card.dataset.presetId === selected.value); - await Promise.all( - cards.map(async (card) => { - const presetId = card.dataset.presetId; - const nameInput = card.querySelector(`[name='preset_name_${presetId}']`); - const promptInput = card.querySelector(`[name='preset_prompt_${presetId}']`); - const presetResponse = await fetch(`/api/ai-prompt-presets/${presetId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: nameInput?.value || `${card.dataset.presetSlot || ""}号方案`, - prompt_text: promptInput?.value || "", - }), - }); - const presetData = await presetResponse.json(); - if (!presetResponse.ok) { - throw new Error(presetData.detail || "AI Prompt 方案保存失败"); - } - return presetData; - }) - ); - - const response = await fetch(`/api/tasks/${taskId}/ai-prompt-preset`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ai_prompt_preset_id: selected.value, - }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "AI Prompt 方案选择保存失败"); - } - return data; -} - -if (aiAnalysisForm) { - aiAnalysisForm.querySelectorAll("input[name='ai_prompt_preset_id']").forEach((radio) => { - radio.addEventListener("change", updatePromptPresetCards); - }); - aiAnalysisForm.querySelectorAll("[data-prompt-preset-card] input[type='text']").forEach((input) => { - input.addEventListener("input", () => { - if (aiProcessResult) aiProcessResult.textContent = "Prompt 方案内容已修改,分析前会自动保存。"; - }); - }); - updatePromptPresetCards(); -} - -if (saveAiPromptsButton && aiAnalysisForm) { - saveAiPromptsButton.addEventListener("click", async () => { - if (isAiAnalysisBusy) return; - const originalText = saveAiPromptsButton.textContent; - saveAiPromptsButton.disabled = true; - saveAiPromptsButton.textContent = "保存中..."; - if (aiProcessResult) aiProcessResult.textContent = "正在保存 AI Prompt 方案..."; - - try { - const data = await saveTaskAiPromptSettings(); - await saveTaskCandidateClipCount(); - await saveTaskSelectionSettings(); - if (aiProcessResult) aiProcessResult.textContent = data.message || "AI Prompt 方案已保存。"; - } catch (error) { - if (aiProcessResult) aiProcessResult.textContent = `保存失败:${error.message}`; - } finally { - saveAiPromptsButton.disabled = isAiAnalysisBusy; - saveAiPromptsButton.textContent = originalText; - } - }); -} - -aiProcessButtons.forEach((button) => { - button.addEventListener("click", async () => { - if (!aiAnalysisForm || isAiAnalysisBusy) return; - const originalText = button.textContent; - const taskId = aiAnalysisForm.dataset.taskId; - const provider = aiAnalysisProvider?.value || ""; - const selectedCard = getSelectedPromptPresetCard(); - const selectedPrompt = selectedCard?.querySelector("textarea")?.value.trim() || ""; - const selectedName = selectedCard?.querySelector("input[type='text']")?.value.trim() || "当前方案"; - if (!selectedPrompt) { - if (aiProcessResult) aiProcessResult.textContent = "请先填写当前选中的 AI Prompt 方案。"; - return; - } - if (!provider) { - const confirmed = window.confirm(`确认使用“${selectedName}”并沿用任务设置开始分析吗?\n\n历史任务未记录分析方式时使用当前默认设置;失败任务恢复仍沿用原记录。将消耗对应模型额度,并覆盖现有 AI 候选结果。`); - if (!confirmed) return; - } else if (provider === "codex") { - const confirmed = window.confirm(`确认使用“${selectedName}”发起 Codex CLI 分析吗?\n\n这会消耗当前 Codex 套餐额度,并覆盖现有 AI 候选结果。`); - if (!confirmed) return; - } else if (provider === "remote") { - const confirmed = window.confirm(`确认使用“${selectedName}”发起远程 AI 分析吗?\n\n这会重新生成候选片段,并覆盖当前已有的 AI 候选结果。`); - if (!confirmed) return; - } - isAiAnalysisBusy = true; - setAiAnalysisControlsDisabled(true); - button.textContent = "分析中..."; - if (aiProcessResult) aiProcessResult.textContent = "正在保存 Prompt 方案并启动 AI 分析..."; - renderAiAnalysisProgress({ - status: "running", - percent: 18, - message: "正在保存 Prompt 方案并启动 AI 分析...", - }); - startTaskLiveStatusPolling(true); - - try { - await saveTaskAiPromptSettings(); - await saveTaskCandidateClipCount(); - await saveTaskSelectionSettings(); - pollAiAnalysisStatus(true).catch(() => {}); - const response = await fetch(`/api/tasks/${taskId}/process/ai${provider ? `?provider=${provider}` : ""}`, { - method: "POST", - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "AI 分析失败"); - } - if (!data.job_id) throw new Error("AI 分析队列没有返回 job_id"); - if (aiProcessResult) aiProcessResult.textContent = data.message || "AI 分析已加入队列。"; - const completedJob = await waitForAiAnalysisJob(data.job_id); - const result = completedJob.result_json || {}; - if (aiProcessResult) aiProcessResult.textContent = result.message || completedJob.message || "AI 分析完成。"; - await pollAiAnalysisStatus(false).catch(() => {}); - if (aiCandidateCountPill && Number.isFinite(Number(result.clip_count))) { - aiCandidateCountPill.textContent = `${Number(result.clip_count)} 条候选`; - } - const historyResponse = await fetch(`/api/tasks/${taskId}/ai-analysis-runs`); - const historyData = await historyResponse.json(); - if (!historyResponse.ok) throw new Error(historyData.detail || "读取 AI 分析历史失败"); - renderAiAnalysisSummary(historyData.latest || result); - renderAiAnalysisHistory(historyData.runs || aiAnalysisRuns); - } catch (error) { - if (aiProcessResult) aiProcessResult.textContent = `AI 分析失败:${summarizeErrorMessage(error.message)}`; - await pollAiAnalysisStatus(false).catch(() => {}); - } finally { - stopAiAnalysisStatusPolling(); - isAiAnalysisBusy = false; - setAiAnalysisControlsDisabled(false); - button.textContent = originalText; - } - }); -}); - -if (aiAnalysisForm) { - const latestAiAnalysis = readJsonScript("latest-ai-analysis-data", null); - const initialAiAnalysisRuns = readJsonScript("ai-analysis-runs-data", []); - renderAiAnalysisHistory(initialAiAnalysisRuns); - if (latestAiAnalysis) { - renderAiAnalysisSummary(latestAiAnalysis); - } - pollAiAnalysisStatus(false).catch(() => {}); -} - -if (showAiHistoryButton && aiAnalysisHistory) { - showAiHistoryButton.addEventListener("click", () => { - aiAnalysisHistory.hidden = !aiAnalysisHistory.hidden; - }); -} - -if (refreshAiHistoryButton) { - refreshAiHistoryButton.addEventListener("click", async () => { - const originalText = refreshAiHistoryButton.textContent; - refreshAiHistoryButton.disabled = true; - refreshAiHistoryButton.textContent = "刷新中..."; - try { - await refreshAiAnalysisHistory(); - if (aiProcessResult) aiProcessResult.textContent = "历史分析结果已刷新。"; - } catch (error) { - if (aiProcessResult) aiProcessResult.textContent = `刷新历史失败:${error.message}`; - } finally { - refreshAiHistoryButton.disabled = false; - refreshAiHistoryButton.textContent = originalText; - } - }); -} - -if (aiAnalysisHistoryList) { - aiAnalysisHistoryList.addEventListener("click", async (event) => { - const button = event.target.closest("[data-restore-run-id]"); - if (!button || !aiAnalysisForm) return; - const runId = button.dataset.restoreRunId; - const confirmed = window.confirm("确认恢复这次 AI 分析结果吗?\n\n当前片段审核页的候选片段会被这次历史结果覆盖。"); - if (!confirmed) return; - - const taskId = aiAnalysisForm.dataset.taskId; - const originalText = button.textContent; - button.disabled = true; - button.textContent = "恢复中..."; - try { - const response = await fetch(`/api/tasks/${taskId}/ai-analysis-runs/${runId}/restore`, { - method: "POST", - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "恢复失败"); - } - if (aiProcessResult) aiProcessResult.textContent = data.message || "历史结果已恢复。"; - if (aiCandidateCountPill && Array.isArray(data.clips)) { - aiCandidateCountPill.textContent = `${data.clips.length} 条候选`; - } - renderAiAnalysisSummary(data.restored_run || data.latest); - renderAiAnalysisHistory(data.runs || aiAnalysisRuns); - } catch (error) { - if (aiProcessResult) aiProcessResult.textContent = `恢复失败:${error.message}`; - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -} - -const clipFilterForm = document.querySelector("#clip-filter-form"); -if (clipFilterForm) { - clipFilterForm.querySelectorAll("select").forEach((select) => { - select.addEventListener("change", () => clipFilterForm.submit()); - }); -} - -const clipReviewForm = document.querySelector("#clip-review-form"); -const saveClipsButton = document.querySelector("#save-clips-button"); -const generateClipsButton = document.querySelector("#generate-clips-button"); -const clipReviewMessage = document.querySelector("#clip-review-message"); -const clipSelectAll = document.querySelector("[data-clip-select-all]"); -const clipSelectAllLabel = document.querySelector("[data-clip-select-all-label]"); -const clipSelectCount = document.querySelector("[data-clip-select-count]"); -const syncReviewedClipsButton = document.querySelector("[data-sync-reviewed-clips='true']"); -const cutJobProgress = document.querySelector("#cut-job-progress"); -const cutJobStatus = document.querySelector("#cut-job-status"); -const cutJobPercent = document.querySelector("#cut-job-percent"); -const cutJobProgressBar = document.querySelector("#cut-job-progress-bar"); -const cutJobMessage = document.querySelector("#cut-job-message"); -const clipPreviewVideo = document.querySelector("#clip-preview-video"); -const clipPreviewDock = document.querySelector("#clip-preview-dock"); -const clipPreviewCaption = document.querySelector("#clip-preview-caption"); -const clipTranscriptDrawer = document.querySelector("#clip-transcript-drawer"); -const clipTranscriptTitle = document.querySelector("#clip-transcript-title"); -const clipTranscriptTime = document.querySelector("#clip-transcript-time"); -const clipTranscriptBody = document.querySelector("#clip-transcript-body"); -const closeTranscriptDrawerButton = document.querySelector("#close-transcript-drawer"); -const sourceMonitorModal = document.querySelector("#source-monitor-modal"); -const closeSourceMonitorButton = document.querySelector("#close-source-monitor"); -const cancelSourceMonitorButton = document.querySelector("#cancel-source-monitor"); -const applySourceMonitorButton = document.querySelector("#apply-source-monitor"); -const sourceMonitorVideo = document.querySelector("#source-monitor-video"); -const sourceMonitorTrack = document.querySelector("#source-monitor-track"); -const sourceMonitorSlider = document.querySelector("#source-monitor-slider"); -const sourceMonitorPlayhead = document.querySelector("#source-monitor-playhead"); -const sourceMonitorCurrent = document.querySelector("#source-monitor-current"); -const sourceMonitorDuration = document.querySelector("#source-monitor-duration"); -const sourceMonitorZoom = document.querySelector("#source-monitor-zoom"); -const sourceMonitorWindowStart = document.querySelector("#source-monitor-window-start"); -const sourceMonitorWindowEnd = document.querySelector("#source-monitor-window-end"); -const sourceMonitorInTime = document.querySelector("#source-monitor-in-time"); -const sourceMonitorOutTime = document.querySelector("#source-monitor-out-time"); -const sourceMonitorMessage = document.querySelector("#source-monitor-message"); -let activePreviewEndSeconds = null; -let activeSourceMonitor = null; -let isSyncingSourceSlider = false; -let isCutJobActive = false; -let isClipSaveActive = false; -let isReviewHandoffActive = false; -let savedClipReviewPayload = clipReviewForm ? collectClipReviewPayload() : []; -let savedEnabledClipCount = Number(clipReviewForm?.dataset.enabledCount || 0); - -const cutJobStatusLabels = { - queued: "排队中", - running: "运行中", - completed: "已完成", - failed: "失败", - cancelled: "已取消", -}; - -function showClipReviewMessage(message, tone = "info") { - if (!clipReviewMessage) return; - clipReviewMessage.hidden = false; - clipReviewMessage.textContent = message; - clipReviewMessage.dataset.tone = tone; -} - -function renderCutJobProgress(job, fallbackMessage = "") { - if (!cutJobProgress || !job) return; - const status = job.status || "queued"; - const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); - cutJobProgress.hidden = false; - cutJobProgress.dataset.status = status; - if (cutJobStatus) { - cutJobStatus.textContent = `${status}(${cutJobStatusLabels[status] || "处理中"})`; - } - if (cutJobPercent) cutJobPercent.textContent = `${progress}%`; - if (cutJobProgressBar) cutJobProgressBar.style.width = `${progress}%`; - if (cutJobMessage) { - cutJobMessage.textContent = status === "failed" - ? (job.error_message || job.message || "切片任务失败") - : (job.queue_hint || job.message || fallbackMessage || "正在等待切片任务更新..."); - } -} - -function wait(milliseconds) { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); -} - -async function waitForCutJob(jobId) { - while (true) { - const response = await fetch(`/api/tasks/jobs/${jobId}`); - const job = await response.json(); - if (!response.ok) { - throw new Error(job.detail || "查询切片任务进度失败"); - } - renderCutJobProgress(job); - if (job.status === "completed") return job; - if (job.status === "failed" || job.status === "cancelled") { - throw new Error(job.error_message || job.message || "切片任务未完成"); - } - await wait(1000); - } -} - -async function showCompletedCut(jobId) { - const completedJob = await waitForCutJob(jobId); - const syncMessage = completedJob.result_json?.publish_sync?.message - ? ` ${completedJob.result_json.publish_sync.message}` : ""; - showClipReviewMessage( - `${completedJob.result_json?.message || completedJob.message || "切片生成完成。"}${syncMessage} 正在刷新切片结果...`, - completedJob.result_json?.publish_sync?.status === "partial" ? "error" : "success", - ); - window.setTimeout(() => window.location.reload(), 700); -} - -async function restoreCutProgress() { - if (!generateClipsButton || !clipReviewForm) return; - isCutJobActive = true; - updateClipReviewActionState(); - const originalText = generateClipsButton.textContent; - try { - const response = await fetch(`/api/tasks/${clipReviewForm.dataset.taskId}/jobs`); - const data = await response.json(); - if (!response.ok) throw new Error(data.detail || "读取切片进度失败"); - const job = (data.jobs || []).find(item => item.job_type === "video_cut" && ["queued", "running"].includes(item.status)); - if (job) { - generateClipsButton.textContent = "切片处理中..."; - renderCutJobProgress(job); - showClipReviewMessage("切片正在后台继续,可以离开页面处理其他任务。", "info"); - await showCompletedCut(job.id); - } - } catch (error) { - showClipReviewMessage(`读取切片进度失败:${error.message}`, "error"); - } finally { - isCutJobActive = false; - updateClipReviewActionState(); - generateClipsButton.textContent = originalText; - } -} - -function getClipReviewCards() { - return Array.from(document.querySelectorAll("[data-clip-card]")); -} - -function getClipEnableCheckboxes() { - return getClipReviewCards() - .map((card) => card.querySelector("[name='enabled']")) - .filter(Boolean); -} - -function updateClipSelectAllUi() { - const checkboxes = getClipEnableCheckboxes(); - const enabledCount = checkboxes.filter((checkbox) => checkbox.checked).length; - const allEnabled = checkboxes.length > 0 && enabledCount === checkboxes.length; - if (clipSelectAll) { - clipSelectAll.disabled = checkboxes.length === 0 || isCutJobActive; - clipSelectAll.checked = allEnabled; - clipSelectAll.indeterminate = enabledCount > 0 && !allEnabled; - } - if (clipSelectAllLabel) clipSelectAllLabel.textContent = allEnabled ? "取消全选" : "全选当前列表"; - if (clipSelectCount) clipSelectCount.textContent = `已启用 ${enabledCount} / ${checkboxes.length} 条`; -} - -function updateClipReviewActionState() { - const hasCards = getClipReviewCards().length > 0; - const busy = isCutJobActive || isClipSaveActive || isReviewHandoffActive; - const payload = clipReviewForm ? collectClipReviewPayload() : []; - const dirty = JSON.stringify(payload) !== JSON.stringify(savedClipReviewPayload); - const enabledCount = savedEnabledClipCount - savedClipReviewPayload.filter(item => item.enabled).length - + payload.filter(item => item.enabled).length; - if (saveClipsButton) saveClipsButton.disabled = !hasCards || busy; - if (generateClipsButton) generateClipsButton.disabled = !hasCards || busy || enabledCount === 0; - if (syncReviewedClipsButton) syncReviewedClipsButton.disabled = !hasCards || busy; - clipReviewForm?.querySelectorAll("input, textarea, [data-delete-trigger], [data-source-monitor-trigger], [data-reject-reason]").forEach(node => { node.disabled = busy; }); - clipFilterForm?.querySelectorAll("select").forEach(node => { node.disabled = busy || dirty; }); - updateClipSelectAllUi(); - if (clipSelectAll) clipSelectAll.disabled = !hasCards || busy; - if (clipReviewForm) { - Object.assign(clipReviewForm.dataset, {dirty: String(dirty), busy: String(busy), enabledCount: String(enabledCount)}); - document.dispatchEvent(new CustomEvent("clip-review-state")); - } -} - -document.addEventListener("production-review-busy", event => { - isReviewHandoffActive = event.detail.busy; - updateClipReviewActionState(); -}); - -function collectClipReviewPayload() { - const cards = getClipReviewCards(); - return cards.map((card) => { - const enabled = card.querySelector("[name='enabled']").checked; - return { - id: card.dataset.clipId, - title: card.querySelector("[name='title']").value.trim(), - start_time: card.querySelector("[name='start_time']").value.trim(), - end_time: card.querySelector("[name='end_time']").value.trim(), - enabled, - summary: card.querySelector("[name='summary']").value.trim(), - feedback_reason_code: enabled ? null : (card.dataset.feedbackReason || null), - }; - }); -} - -async function persistClipReviewChanges() { - if (!clipReviewForm) throw new Error("当前页面没有可保存的候选片段"); - const taskId = clipReviewForm.dataset.taskId; - const payload = collectClipReviewPayload(); - const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clips: payload }), - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.detail || "保存失败"); - savedClipReviewPayload = payload; - savedEnabledClipCount = data.clips.filter(item => item.enabled).length; - updateClipReviewActionState(); - document.dispatchEvent(new CustomEvent("clip-review-saved")); - return data; -} - -async function deleteClipCard(card, button) { - if (!clipReviewForm || !card || isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; - const taskId = clipReviewForm.dataset.taskId; - const clipId = card.dataset.clipId; - const originalText = button?.textContent || ""; - isClipSaveActive = true; - updateClipReviewActionState(); - if (button) { - button.disabled = true; - button.textContent = "\u5220\u9664\u4e2d..."; - } - card.classList.add("is-removing"); - showClipReviewMessage("\u6b63\u5728\u5220\u9664\u8fd9\u6761\u5019\u9009\u7247\u6bb5...", "info"); - - try { - const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}`, { method: "DELETE" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "\u5220\u9664\u5931\u8d25"); - } - if (activeSourceMonitor?.card === card) { - toggleSourceMonitor(false); - } - savedEnabledClipCount -= Number(!!savedClipReviewPayload.find(item => item.id === clipId)?.enabled); - savedClipReviewPayload = savedClipReviewPayload.filter(item => item.id !== clipId); - card.remove(); - updateClipReviewActionState(); - document.dispatchEvent(new CustomEvent("clip-review-saved")); - closeTranscriptDrawer(); - showClipReviewMessage( - data.message || "\u5df2\u5220\u9664\u8be5\u5019\u9009\u7247\u6bb5\uff0c\u540e\u7eed\u751f\u6210\u5207\u7247\u4e0d\u4f1a\u518d\u4f7f\u7528\u5b83\u3002", - "success", - ); - } catch (error) { - card.classList.remove("is-removing"); - if (button) { - button.disabled = false; - button.textContent = originalText; - } - showClipReviewMessage(`\u5220\u9664\u5931\u8d25\uff1a${error.message}`, "error"); - } finally { - isClipSaveActive = false; - updateClipReviewActionState(); - } -} - -function syncRejectReasonVisibility(card) { - if (!card) return; - const enabledInput = card.querySelector("[name='enabled']"); - const rejectFeedback = card.querySelector("[data-reject-feedback]"); - if (rejectFeedback && enabledInput) rejectFeedback.hidden = enabledInput.checked; -} - -function timeTextToSeconds(value) { - const parts = String(value || "") - .trim() - .split(":") - .map((part) => Number(part)); - if (![2, 3].includes(parts.length) || parts.some((part) => !Number.isFinite(part))) { - return 0; - } - if (parts.length === 2) { - return parts[0] * 60 + parts[1]; - } - return parts[0] * 3600 + parts[1] * 60 + parts[2]; -} - -function secondsToTimeText(seconds) { - const total = Math.max(0, Math.round(Number(seconds) || 0)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const restSeconds = total % 60; - return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(restSeconds).padStart(2, "0")}`; -} - -function formatTimecode(seconds) { - return `${secondsToTimeText(seconds)}:00`; -} - -function updateCardTimeDataset(card) { - if (!card) return; - card.dataset.startSeconds = String(timeTextToSeconds(card.querySelector("[name='start_time']")?.value)); - card.dataset.endSeconds = String(timeTextToSeconds(card.querySelector("[name='end_time']")?.value)); -} - -function setSourceMonitorMessage(message, tone = "info") { - if (!sourceMonitorMessage) return; - sourceMonitorMessage.textContent = message; - sourceMonitorMessage.dataset.tone = tone; -} - -function setSourceMonitorControlsDisabled(disabled) { - document.querySelectorAll("[data-source-action], [data-source-step]").forEach((button) => { - button.disabled = disabled; - }); - if (applySourceMonitorButton) applySourceMonitorButton.disabled = disabled; -} - -function getReviewMaxClipSeconds() { - const value = Number(clipReviewForm?.dataset.maxClipSeconds || 0); - return Number.isFinite(value) && value > 0 ? value : 300; -} - -function getSourceVideoDuration() { - const videoDuration = Number(sourceMonitorVideo?.duration || 0); - if (Number.isFinite(videoDuration) && videoDuration > 0) return videoDuration; - if (!activeSourceMonitor) return 1; - return Math.max(activeSourceMonitor.endSeconds + 60, activeSourceMonitor.startSeconds + 61); -} - -function clampSeconds(value, min, max) { - return Math.max(min, Math.min(Number(value) || 0, max)); -} - -function getSourceWindowRange() { - if (!activeSourceMonitor) return { start: 0, end: 1, span: 1 }; - const duration = getSourceVideoDuration(); - const selected = Math.max(1, activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds); - const midpoint = (activeSourceMonitor.startSeconds + activeSourceMonitor.endSeconds) / 2; - const zoom = sourceMonitorZoom?.value || "fit"; - const spanMap = { - fit: Math.max(120, selected * 4), - half: Math.max(60, selected * 2), - quarter: Math.max(30, selected * 1.25), - }; - const span = Math.min(duration, spanMap[zoom] || spanMap.fit); - const start = clampSeconds(midpoint - span / 2, 0, Math.max(0, duration - span)); - return { start, end: start + span, span }; -} - -function normalizeSourceRange(startSeconds, endSeconds, anchor = "end") { - const duration = getSourceVideoDuration(); - const maxClipSeconds = getReviewMaxClipSeconds(); - const minGap = 1; - let start = clampSeconds(startSeconds, 0, Math.max(0, duration - minGap)); - let end = clampSeconds(endSeconds, start + minGap, duration); - let adjusted = false; - - if (end - start > maxClipSeconds) { - adjusted = true; - if (anchor === "start") { - end = Math.min(duration, start + maxClipSeconds); - } else if (anchor === "end") { - start = Math.max(0, end - maxClipSeconds); - } else { - end = Math.min(duration, start + maxClipSeconds); - } - } - - if (end <= start) { - adjusted = true; - if (anchor === "end") { - start = Math.max(0, end - minGap); - } else { - end = Math.min(duration, start + minGap); - } - } - - return { start, end, adjusted }; -} - -function updateSourceMonitorReadout(view = getSourceWindowRange()) { - if (!activeSourceMonitor) return; - const duration = getSourceVideoDuration(); - const currentSeconds = Number(sourceMonitorVideo?.currentTime || activeSourceMonitor.startSeconds); - if (sourceMonitorCurrent) sourceMonitorCurrent.textContent = formatTimecode(currentSeconds); - if (sourceMonitorDuration) sourceMonitorDuration.textContent = `片段 ${secondsToTimeText(activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds)}`; - if (sourceMonitorWindowStart) sourceMonitorWindowStart.textContent = secondsToTimeText(view.start); - if (sourceMonitorWindowEnd) sourceMonitorWindowEnd.textContent = secondsToTimeText(Math.min(duration, view.end)); - if (sourceMonitorInTime) sourceMonitorInTime.textContent = `入点 ${secondsToTimeText(activeSourceMonitor.startSeconds)}`; - if (sourceMonitorOutTime) sourceMonitorOutTime.textContent = `出点 ${secondsToTimeText(activeSourceMonitor.endSeconds)}`; -} - -function updateSourceMonitorPlayhead(view = getSourceWindowRange()) { - if (!activeSourceMonitor || !sourceMonitorPlayhead) return; - const currentSeconds = Number(sourceMonitorVideo?.currentTime || activeSourceMonitor.startSeconds); - const ratio = (clampSeconds(currentSeconds, view.start, view.end) - view.start) / Math.max(1, view.span); - const leftPadding = 18; - const rightPadding = 18; - const trackWidth = sourceMonitorTrack?.clientWidth || 1; - const usableWidth = Math.max(1, trackWidth - leftPadding - rightPadding); - sourceMonitorPlayhead.style.left = `${leftPadding + ratio * usableWidth}px`; -} - -function handleSourceSliderUpdate(values, handle, unencoded) { - if (!activeSourceMonitor || isSyncingSourceSlider) return; - const start = Number(unencoded?.[0] ?? values?.[0]); - const end = Number(unencoded?.[1] ?? values?.[1]); - if (!Number.isFinite(start) || !Number.isFinite(end)) return; - activeSourceMonitor.startSeconds = Math.round(start); - activeSourceMonitor.endSeconds = Math.round(end); - updateSourceMonitorReadout(); - updateSourceMonitorPlayhead(); -} - -function syncSourceSliderOptions(view = getSourceWindowRange()) { - if (!activeSourceMonitor || !sourceMonitorSlider) return false; - if (!window.noUiSlider) { - sourceMonitorSlider.dataset.disabled = "true"; - setSourceMonitorMessage("剪辑滑块组件加载失败,请刷新页面后再试。", "error"); - setSourceMonitorControlsDisabled(true); - return false; - } - sourceMonitorSlider.dataset.disabled = "false"; - setSourceMonitorControlsDisabled(false); - - const options = { - start: [activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds], - connect: [false, true, false], - behaviour: "tap-drag", - step: Number(activeSourceMonitor.stepSeconds || 1), - margin: 1, - limit: getReviewMaxClipSeconds(), - range: { - min: view.start, - max: Math.max(view.start + 1, view.end), - }, - pips: { - mode: "count", - values: 9, - density: 4, - }, - }; - - isSyncingSourceSlider = true; - if (sourceMonitorSlider.noUiSlider) { - sourceMonitorSlider.noUiSlider.updateOptions(options, false); - } else { - window.noUiSlider.create(sourceMonitorSlider, options); - sourceMonitorSlider.noUiSlider.on("update", handleSourceSliderUpdate); - sourceMonitorSlider.noUiSlider.on("slide", (values, handle, unencoded) => { - if (!sourceMonitorVideo || !activeSourceMonitor) return; - const nextTime = Number(unencoded?.[handle] ?? values?.[handle]); - if (Number.isFinite(nextTime)) sourceMonitorVideo.currentTime = clampSeconds(nextTime, 0, getSourceVideoDuration()); - }); - sourceMonitorSlider.noUiSlider.on("change", () => { - setSourceMonitorMessage("已更新入点 / 出点。点击“应用到片段”后,再保存修改。", "success"); - }); - } - sourceMonitorSlider.noUiSlider.set([activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds]); - isSyncingSourceSlider = false; - return true; -} - -function renderSourceMonitor() { - if (!activeSourceMonitor) return; - const view = getSourceWindowRange(); - syncSourceSliderOptions(view); - updateSourceMonitorReadout(view); - updateSourceMonitorPlayhead(view); -} - -function updateSourceMonitorRange(startSeconds, endSeconds, anchor = "end", seekTo = null) { - if (!activeSourceMonitor) return; - const normalized = normalizeSourceRange(startSeconds, endSeconds, anchor); - activeSourceMonitor.startSeconds = normalized.start; - activeSourceMonitor.endSeconds = normalized.end; - renderSourceMonitor(); - if (sourceMonitorVideo && seekTo) { - sourceMonitorVideo.currentTime = seekTo === "out" ? activeSourceMonitor.endSeconds : activeSourceMonitor.startSeconds; - } - if (normalized.adjusted) { - setSourceMonitorMessage(`已按任务限制自动收紧范围,单条最长 ${Math.round(getReviewMaxClipSeconds() / 60)} 分钟。`, "info"); - } -} - -function setSourceMonitorTime(seconds) { - if (!sourceMonitorVideo) return; - sourceMonitorVideo.currentTime = clampSeconds(seconds, 0, getSourceVideoDuration()); - renderSourceMonitor(); -} - -function toggleSourceMonitor(show) { - if (!sourceMonitorModal) return; - if (show) { - sourceMonitorModal.removeAttribute("hidden"); - document.body.style.overflow = "hidden"; - return; - } - sourceMonitorModal.setAttribute("hidden", ""); - document.body.style.overflow = ""; - activeSourceMonitor = null; - isSyncingSourceSlider = false; - if (sourceMonitorVideo) sourceMonitorVideo.pause(); -} - -function openSourceMonitor(card) { - if (!card || !sourceMonitorVideo) { - showClipReviewMessage("没有找到源视频,暂时无法打开源监视器。", "error"); - return; - } - updateCardTimeDataset(card); - const startSeconds = Number(card.dataset.startSeconds || 0); - const endSeconds = Number(card.dataset.endSeconds || startSeconds + 1); - activeSourceMonitor = { - card, - startSeconds, - endSeconds, - isPreviewing: false, - stepSeconds: 1, - }; - const normalized = normalizeSourceRange(startSeconds, endSeconds, "end"); - activeSourceMonitor = { - card, - startSeconds: normalized.start, - endSeconds: normalized.end, - isPreviewing: false, - stepSeconds: 1, - }; - sourceMonitorVideo.currentTime = activeSourceMonitor.startSeconds; - if (sourceMonitorZoom) sourceMonitorZoom.value = "fit"; - document.querySelectorAll("[data-source-step]").forEach((button) => { - button.classList.toggle("active", button.dataset.sourceStep === "1"); - }); - setSourceMonitorMessage("拖动蓝色范围左右手柄,或播放到合适位置后设置入点 / 出点。"); - toggleSourceMonitor(true); - renderSourceMonitor(); -} - -function applySourceMonitorToCard() { - if (!activeSourceMonitor?.card) return; - const card = activeSourceMonitor.card; - const startInput = card.querySelector("[name='start_time']"); - const endInput = card.querySelector("[name='end_time']"); - if (startInput) startInput.value = secondsToTimeText(activeSourceMonitor.startSeconds); - if (endInput) endInput.value = secondsToTimeText(activeSourceMonitor.endSeconds); - updateCardTimeDataset(card); - const durationPill = card.querySelector(".status-pill"); - if (durationPill) durationPill.textContent = `${Math.round(activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds)} 秒`; - showClipReviewMessage("已应用新的入点 / 出点。确认无误后,请点击右侧“保存修改”写入数据库。", "success"); - updateClipReviewActionState(); - toggleSourceMonitor(false); -} - -document.querySelectorAll("[data-clip-card] input[name='start_time'], [data-clip-card] input[name='end_time']").forEach((input) => { - input.addEventListener("change", () => updateCardTimeDataset(input.closest("[data-clip-card]"))); -}); - -if (clipSelectAll) { - clipSelectAll.addEventListener("change", () => { - const shouldEnable = clipSelectAll.checked; - getClipEnableCheckboxes().forEach((checkbox) => { - checkbox.checked = shouldEnable; - syncRejectReasonVisibility(checkbox.closest("[data-clip-card]")); - }); - updateClipReviewActionState(); - showClipReviewMessage( - shouldEnable - ? "已全选当前列表。确认无误后,请点击“保存修改”写入数据库。" - : "已取消当前列表的全部选择。确认无误后,请点击“保存修改”写入数据库。", - "info", - ); - }); -} - -clipReviewForm?.addEventListener("change", (event) => { - if (event.target.matches("[data-clip-card] input[name='enabled']")) { - syncRejectReasonVisibility(event.target.closest("[data-clip-card]")); - updateClipSelectAllUi(); - } - updateClipReviewActionState(); -}); -clipReviewForm?.addEventListener("input", (event) => { - // The bulk checkbox applies its selection in change; do not reset it before that event. - if (event.target.matches("[data-clip-card] input, [data-clip-card] textarea")) updateClipReviewActionState(); -}); - -updateClipSelectAllUi(); - -if (saveClipsButton && clipReviewForm) { - saveClipsButton.addEventListener("click", async () => { - if (isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; - const taskId = clipReviewForm.dataset.taskId; - const originalText = saveClipsButton.textContent; - isClipSaveActive = true; - updateClipReviewActionState(); - saveClipsButton.textContent = "正在保存..."; - showClipReviewMessage("正在保存候选片段修改...", "info"); - - try { - const data = await persistClipReviewChanges(); - showClipReviewMessage(data.message || "保存成功。", "success"); - } catch (error) { - showClipReviewMessage(`保存失败:${error.message}`, "error"); - } finally { - isClipSaveActive = false; - updateClipReviewActionState(); - saveClipsButton.textContent = originalText; - } - }); -} - -function playClipPreview(card) { - if (!clipPreviewVideo || !card) return; - updateCardTimeDataset(card); - const startSeconds = Number(card.dataset.startSeconds || 0); - const endSeconds = Number(card.dataset.endSeconds || 0); - activePreviewEndSeconds = Number.isFinite(endSeconds) && endSeconds > startSeconds ? endSeconds : null; - clipPreviewVideo.currentTime = Math.max(0, startSeconds); - clipPreviewVideo.play().catch(() => {}); - if (clipPreviewCaption) { - const title = card.dataset.title || "当前片段"; - clipPreviewCaption.textContent = `${title}:从 ${card.querySelector("[name='start_time']")?.value || ""} 播放到 ${card.querySelector("[name='end_time']")?.value || ""}`; - } - ensureClipPreviewVisible(); -} - -function ensureClipPreviewVisible() { - const previewTarget = clipPreviewDock || clipPreviewVideo; - if (!previewTarget) return; - const rect = previewTarget.getBoundingClientRect(); - const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; - const isVisible = rect.top >= 0 && rect.top < viewportHeight * 0.72 && rect.bottom > Math.min(120, viewportHeight); - if (isVisible) return; - previewTarget.scrollIntoView({ behavior: preferredScrollBehavior(), block: "start", inline: "nearest" }); -} - -function closeTranscriptDrawer() { - if (!clipTranscriptDrawer) return; - clipTranscriptDrawer.hidden = true; - document.body.classList.remove("transcript-drawer-open"); -} - -function renderTranscriptRows(rows) { - if (!clipTranscriptBody) return; - clipTranscriptBody.replaceChildren(); - if (!rows.length) { - const empty = document.createElement("p"); - empty.className = "empty-note"; - empty.textContent = "这一段暂时没有匹配到逐句转写。可以先查看完整转写,或重新生成转写后再试。"; - clipTranscriptBody.append(empty); - return; - } - rows.forEach((row) => { - const item = document.createElement("article"); - item.className = "transcript-line"; - const time = document.createElement("time"); - time.textContent = `${row.start_time} - ${row.end_time}`; - const text = document.createElement("p"); - text.textContent = row.text; - item.append(time, text); - clipTranscriptBody.append(item); - }); -} - -async function openTranscriptDrawer(card) { - if (!clipTranscriptDrawer || !clipReviewForm || !card) return; - updateCardTimeDataset(card); - const taskId = clipReviewForm.dataset.taskId; - const clipId = card.dataset.clipId; - clipTranscriptDrawer.hidden = false; - document.body.classList.add("transcript-drawer-open"); - if (clipTranscriptTitle) clipTranscriptTitle.textContent = card.dataset.title || "片段转写"; - if (clipTranscriptTime) { - const startTime = card.querySelector("[name='start_time']")?.value || ""; - const endTime = card.querySelector("[name='end_time']")?.value || ""; - clipTranscriptTime.textContent = `${startTime} - ${endTime}`; - } - if (clipTranscriptBody) { - clipTranscriptBody.replaceChildren(); - const loading = document.createElement("p"); - loading.className = "empty-note"; - loading.textContent = "正在读取这一段转写..."; - clipTranscriptBody.append(loading); - } - try { - const startTime = card.querySelector("[name='start_time']")?.value || ""; - const endTime = card.querySelector("[name='end_time']")?.value || ""; - const params = new URLSearchParams({ start_time: startTime, end_time: endTime }); - const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}/transcript-excerpt?${params.toString()}`); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "读取转写失败"); - } - if (clipTranscriptTitle) clipTranscriptTitle.textContent = data.title || "片段转写"; - if (clipTranscriptTime) clipTranscriptTime.textContent = `${data.start_time} - ${data.end_time}`; - renderTranscriptRows(data.rows || []); - } catch (error) { - if (clipTranscriptBody) { - clipTranscriptBody.replaceChildren(); - const failed = document.createElement("p"); - failed.className = "empty-note"; - failed.textContent = `读取失败:${error.message}`; - clipTranscriptBody.append(failed); - } - } -} - -document.querySelectorAll("[data-preview-trigger]").forEach((button) => { - button.addEventListener("click", () => { - playClipPreview(button.closest("[data-clip-card]")); - }); -}); - -document.querySelectorAll("[data-transcript-trigger]").forEach((button) => { - button.addEventListener("click", () => { - openTranscriptDrawer(button.closest("[data-clip-card]")); - }); -}); - -document.querySelectorAll("[data-source-monitor-trigger]").forEach((button) => { - button.addEventListener("click", () => { - openSourceMonitor(button.closest("[data-clip-card]")); - }); -}); - -document.querySelectorAll("[data-delete-trigger]").forEach((button) => { - button.addEventListener("click", () => { - deleteClipCard(button.closest("[data-clip-card]"), button); - }); -}); - -document.querySelectorAll("[data-reject-reason]").forEach((button) => { - button.addEventListener("click", () => { - const card = button.closest("[data-clip-card]"); - if (!card) return; - const selected = card.dataset.feedbackReason === button.dataset.rejectReason; - card.dataset.feedbackReason = selected ? "" : button.dataset.rejectReason; - card.querySelectorAll("[data-reject-reason]").forEach((item) => { - const active = !selected && item === button; - item.classList.toggle("is-active", active); - item.setAttribute("aria-pressed", active ? "true" : "false"); - }); - showClipReviewMessage("淘汰原因已暂存;点击“保存修改”后统一写入。", "info"); - updateClipReviewActionState(); - }); -}); - -if (closeTranscriptDrawerButton) { - closeTranscriptDrawerButton.addEventListener("click", closeTranscriptDrawer); -} - -if (clipPreviewVideo) { - clipPreviewVideo.addEventListener("timeupdate", () => { - if (activePreviewEndSeconds !== null && clipPreviewVideo.currentTime >= activePreviewEndSeconds) { - clipPreviewVideo.pause(); - activePreviewEndSeconds = null; - } - }); -} - -if (closeSourceMonitorButton) { - closeSourceMonitorButton.addEventListener("click", () => toggleSourceMonitor(false)); -} - -if (cancelSourceMonitorButton) { - cancelSourceMonitorButton.addEventListener("click", () => toggleSourceMonitor(false)); -} - -if (applySourceMonitorButton) { - applySourceMonitorButton.addEventListener("click", applySourceMonitorToCard); -} - -if (sourceMonitorModal) { - sourceMonitorModal.addEventListener("click", (event) => { - if (event.target === sourceMonitorModal) toggleSourceMonitor(false); - }); -} - -if (sourceMonitorZoom) { - sourceMonitorZoom.addEventListener("change", renderSourceMonitor); -} - -document.querySelectorAll("[data-source-step]").forEach((button) => { - button.addEventListener("click", () => { - if (!activeSourceMonitor) return; - document.querySelectorAll("[data-source-step]").forEach((item) => item.classList.remove("active")); - button.classList.add("active"); - activeSourceMonitor.stepSeconds = Number(button.dataset.sourceStep || 1); - renderSourceMonitor(); - }); -}); - -document.querySelectorAll("[data-source-action]").forEach((button) => { - button.addEventListener("click", () => { - if (!activeSourceMonitor || !sourceMonitorVideo) return; - const action = button.dataset.sourceAction; - const current = Number(sourceMonitorVideo.currentTime || activeSourceMonitor.startSeconds); - const step = Number(activeSourceMonitor.stepSeconds || 1); - if (action === "mark-in") updateSourceMonitorRange(current, activeSourceMonitor.endSeconds, "start", "in"); - if (action === "mark-out") updateSourceMonitorRange(activeSourceMonitor.startSeconds, current, "end", "out"); - if (action === "jump-in") setSourceMonitorTime(activeSourceMonitor.startSeconds); - if (action === "jump-out") setSourceMonitorTime(activeSourceMonitor.endSeconds); - if (action === "preview") { - activeSourceMonitor.isPreviewing = true; - setSourceMonitorTime(activeSourceMonitor.startSeconds); - sourceMonitorVideo.play().catch(() => {}); - } - if (action === "in-back") updateSourceMonitorRange(activeSourceMonitor.startSeconds - step, activeSourceMonitor.endSeconds, "start", "in"); - if (action === "in-forward") updateSourceMonitorRange(activeSourceMonitor.startSeconds + step, activeSourceMonitor.endSeconds, "start", "in"); - if (action === "out-back") updateSourceMonitorRange(activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds - step, "end", "out"); - if (action === "out-forward") updateSourceMonitorRange(activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds + step, "end", "out"); - }); -}); - -if (sourceMonitorVideo) { - sourceMonitorVideo.addEventListener("loadedmetadata", renderSourceMonitor); - sourceMonitorVideo.addEventListener("timeupdate", () => { - if (!activeSourceMonitor) return; - if (activeSourceMonitor.isPreviewing && sourceMonitorVideo.currentTime >= activeSourceMonitor.endSeconds) { - sourceMonitorVideo.pause(); - activeSourceMonitor.isPreviewing = false; - setSourceMonitorTime(activeSourceMonitor.endSeconds); - setSourceMonitorMessage("当前片段预览已到出点。", "success"); - return; - } - renderSourceMonitor(); - }); -} - -window.addEventListener("resize", () => { - if (activeSourceMonitor) renderSourceMonitor(); -}); - -if (generateClipsButton) { - generateClipsButton.addEventListener("click", async () => { - if (isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; - const originalText = generateClipsButton.textContent; - isCutJobActive = true; - updateClipReviewActionState(); - generateClipsButton.textContent = "正在确认..."; - showClipReviewMessage("正在保存当前审核选择...", "info"); - - try { - await persistClipReviewChanges(); - generateClipsButton.textContent = "切片处理中..."; - showClipReviewMessage("审核选择已保存,正在创建切片后台任务...", "info"); - const response = await fetch(generateClipsButton.dataset.endpoint, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "生成切片请求失败"); - } - renderCutJobProgress(data.job, data.message); - showClipReviewMessage(data.message || "切片任务已加入后台队列。", "info"); - await showCompletedCut(data.job_id); - } catch (error) { - showClipReviewMessage(`生成切片失败:${error.message}`, "error"); - } finally { - isCutJobActive = false; - updateClipReviewActionState(); - generateClipsButton.textContent = originalText; - } - }); - restoreCutProgress(); -} - -async function saveTaskSelectionSettings() { - if (!aiAnalysisForm || !aiSelectionProfile || !aiFinalClipTarget) return null; - const finalTarget = Number(aiFinalClipTarget.value || 5); - if (!Number.isInteger(finalTarget) || finalTarget < 1 || finalTarget > 12) { - throw new Error("最终启用目标必须是 1 到 12 之间的整数。"); - } - const settingsPayload = { - selection_profile: aiSelectionProfile, - final_clip_target: finalTarget, - }; - if (aiSelectionProfile === "long_live_talk") { - settingsPayload.highlight_density_per_hour = Number(aiHighlightDensity?.value || 4); - settingsPayload.highlight_total_limit = Number(aiHighlightTotalLimit?.value || 30); - } - const response = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/selection-settings`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settingsPayload), - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.detail || "选片设置保存失败"); - const visual = document.querySelector("#task-visual-enabled"); - if (visual) { - const visualResponse = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/visual-settings`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ visual_enabled: visual.checked }), - }); - const visualData = await visualResponse.json(); - if (!visualResponse.ok) throw new Error(visualData.detail || "视觉设置保存失败"); - } - return data; -} - -document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { - button.addEventListener("click", async () => { - const taskId = button.dataset.taskId; - if (!taskId) return; - const originalText = button.textContent; - const preferSubtitled = button.dataset.preferSubtitled === "true"; - button.disabled = true; - button.textContent = "正在同步..."; - try { - const syncReviewedClips = button.dataset.syncReviewedClips === "true"; - let data; - if (syncReviewedClips) { - const clips = collectClipReviewPayload(); - if (!clips.some((clip) => clip.enabled)) { - throw new Error("请至少启用一条候选片段后再同步发送中心"); - } - button.textContent = "正在保存并生成..."; - showClipReviewMessage("正在保存审核选择;如选择有变化,将自动生成最新切片并同步...", "info"); - data = await window.apiFetch( - `/api/tasks/${encodeURIComponent(taskId)}/clips/sync-publish`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clips }), - }, - ); - } else { - data = await window.apiFetch( - `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, - { method: "POST" }, - ); - } - const summary = document.querySelector("[data-publish-link-summary]"); - if (summary) { - const heading = document.createElement("strong"); - const detail = document.createElement("span"); - heading.textContent = `发送中心关联:${data.link_state?.label || "同步完成"}`; - detail.textContent = data.message || ""; - summary.replaceChildren(heading, detail); - } - showClipReviewMessage(data.message || "发送中心同步完成。", data.status === "partial" ? "error" : "success"); - if (!document.querySelector("#process-result")) { - window.alert(data.message || "发送中心同步完成。"); - } - if (syncReviewedClips && data.status !== "partial") { - const params = new URLSearchParams({ - task_id: taskId, - tab: "content", - publish_message: data.message || "发送中心同步完成。", - }); - window.location.assign(`/publish?${params.toString()}`); - } - } catch (error) { - const message = `同步发送中心失败:${error.message}`; - showClipReviewMessage(message, "error"); - if (!document.querySelector("#process-result")) window.alert(message); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -document.querySelectorAll(".js-hide-task").forEach((button) => { - button.addEventListener("click", async () => { - const taskTitle = button.dataset.taskTitle || "这条任务"; - const confirmed = window.confirm(`确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\n任务目录外的原始视频不会被删除。`); - if (!confirmed) return; - - const originalText = button.textContent; - button.disabled = true; - button.textContent = "删除中..."; - - try { - const response = await fetch(`/api/tasks/${button.dataset.taskId}`, { method: "DELETE" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "永久删除失败"); - } - const externalNotice = data.external_source_preserved ? "\n\n任务目录外的原始视频已保留。" : ""; - window.alert(`${data.message || "任务已永久删除。"}${externalNotice}`); - window.location.reload(); - } catch (error) { - window.alert(`永久删除失败:${error.message}`); - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -const subtitleStyleForm = document.querySelector("#subtitle-style-form"); -const subtitleStyleResult = document.querySelector("#subtitle-style-result"); - -if (subtitleStyleForm) { - subtitleStyleForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const submitButton = subtitleStyleForm.querySelector("button[type='submit']"); - const formData = new FormData(subtitleStyleForm); - const payload = Object.fromEntries(formData.entries()); - payload.font_size = Number(payload.font_size || 42); - payload.outline_width = Number(payload.outline_width || 3); - payload.shadow_depth = Number(payload.shadow_depth || 1); - payload.safe_area_percent = Number(payload.safe_area_percent || 5); - payload.speaker_styles = { - 主播: { font_color: payload.speaker_host_color || "#ffffff" }, - 嘉宾: { font_color: payload.speaker_guest_color || "#ffd60a" }, - }; - delete payload.speaker_host_color; - delete payload.speaker_guest_color; - payload.shadow_enabled = Boolean(subtitleStyleForm.elements.shadow_enabled?.checked); - if (submitButton) submitButton.disabled = true; - if (subtitleStyleResult) subtitleStyleResult.textContent = "正在保存字幕样式..."; - - try { - const response = await fetch("/api/tasks/subtitle-style", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "字幕样式保存失败"); - } - if (subtitleStyleResult) subtitleStyleResult.textContent = data.message || "字幕样式已保存。"; - } catch (error) { - if (subtitleStyleResult) subtitleStyleResult.textContent = `保存失败:${error.message}`; - } finally { - if (submitButton) submitButton.disabled = false; - } - }); -} - -function renderAiAnalysisProgress(status) { - if (!aiAnalysisProgress) return; - const percent = Math.max(0, Math.min(100, Number(status.percent || 0))); - aiAnalysisProgress.hidden = false; - aiAnalysisProgress.dataset.status = status.status || "idle"; - if (aiAnalysisProgressMessage) { - aiAnalysisProgressMessage.textContent = status.message || "AI 分析进度"; - } - if (aiAnalysisProgressPercent) { - aiAnalysisProgressPercent.textContent = `${percent}%`; - } - if (aiAnalysisProgressBar) { - aiAnalysisProgressBar.style.width = `${percent}%`; - } -} - -function renderRuntimeLog(status) { - if (runtimeLogState) { - const labelMap = { - idle: "待开始", - running: "分析中", - completed: "已完成", - failed: "失败", - }; - const runtimeStatus = status.runtime_status || status.status || "idle"; - runtimeLogState.textContent = status.runtime_status_label - || labelMap[runtimeStatus] - || status.status_label - || status.task_status_label - || "已刷新"; - runtimeLogState.dataset.status = runtimeStatus; - } - if (!runtimeLogLines) return; - const lines = Array.isArray(status.log_lines) ? status.log_lines : []; - runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。任务开始后,这里会自动刷新。"; - runtimeLogLines.scrollTop = runtimeLogLines.scrollHeight; -} - -function formatTaskLiveRefreshTime() { - return new Date().toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); -} - -function renderTaskLiveActions(data) { - if (!taskLiveActions) return; - const actions = data.actions || {}; - let primaryAction = actions.primary || "none"; - if (primaryAction === "publish" && !actions.publish) { - primaryAction = "none"; - } - - taskLiveActions.querySelectorAll("[data-live-primary-action]").forEach((node) => { - node.hidden = node.dataset.livePrimaryAction !== primaryAction; - }); - - const reviewAction = taskLiveActions.querySelector("[data-live-review-action]"); - if (reviewAction) reviewAction.hidden = !actions.review || primaryAction === "review_outputs"; - const syncAction = taskLiveActions.querySelector("[data-live-sync-action]"); - if (syncAction) { - syncAction.hidden = ["subtitle_review", "review_outputs"].includes(primaryAction) || Number(data.counts?.outputs || 0) <= 0; - } - const subtitleSkip = taskLiveActions.querySelector("[data-live-subtitle-skip]"); - if (subtitleSkip) subtitleSkip.hidden = primaryAction !== "subtitle_review" || actions.subtitle_skip === false; -} - -async function waitForAiAnalysisJob(jobId) { - while (true) { - const response = await fetch(`/api/tasks/jobs/${jobId}`); - const job = await response.json(); - if (!response.ok) throw new Error(job.detail || "查询 AI 分析任务进度失败"); - renderAiAnalysisProgress({ - status: job.status, - percent: Number(job.progress || 0), - message: job.status === "failed" - ? (job.error_message || job.message || "AI 分析失败") - : (job.message || "AI 分析正在排队..."), - }); - if (job.status === "completed") return job; - if (job.status === "failed" || job.status === "cancelled") { - throw new Error(job.error_message || job.message || "AI 分析任务未完成"); - } - await wait(1000); - } -} - -function renderTaskLiveStatus(data) { - const progress = Math.max(0, Math.min(100, Number(data.progress || 0))); - document.querySelectorAll("[data-task-live-status-label]").forEach((node) => { - node.textContent = data.status_label || data.status || "状态未知"; - node.dataset.status = data.runtime_status || (data.should_poll ? "running" : "completed"); - }); - const headerStatus = document.querySelector("[data-task-live-header-status]"); - if (headerStatus) headerStatus.textContent = data.status_label || data.status || "状态未知"; - if (taskLiveProgressBar) taskLiveProgressBar.style.width = `${progress}%`; - if (taskLiveProgressNumber) taskLiveProgressNumber.textContent = `${progress}%`; - if (taskLiveUpdatedAt) taskLiveUpdatedAt.textContent = data.updated_at || "未知"; - - const candidateCount = Number(data.counts?.candidates || 0); - const outputCount = Number(data.counts?.outputs || 0); - if (taskLiveCandidateCount) taskLiveCandidateCount.textContent = `${candidateCount} 条`; - if (taskLiveOutputCount) taskLiveOutputCount.textContent = `${outputCount} 条`; - if (aiCandidateCountPill) aiCandidateCountPill.textContent = `${candidateCount} 条候选`; - - const operation = data.active_operation || {}; - const operationProgress = Math.max(0, Math.min(100, Number(operation.progress || 0))); - if (taskLiveOperation) taskLiveOperation.dataset.status = operation.status || "idle"; - if (taskLiveOperationLabel) { - taskLiveOperationLabel.textContent = operation.label || data.task_status_label || "当前任务"; - } - if (taskLiveOperationProgress) taskLiveOperationProgress.textContent = `${operationProgress}%`; - if (taskLiveOperationMessage) { - taskLiveOperationMessage.textContent = operation.message - || `当前阶段:${data.task_status_label || data.status_label || "状态未知"}`; - } - - const allowedStepStates = new Set(["done", "current", "pending", "warning"]); - (Array.isArray(data.workflow_steps) ? data.workflow_steps : []).forEach((step) => { - const node = taskLiveOverview?.querySelector(`[data-task-live-step="${step.index}"]`); - if (!node) return; - node.classList.remove("done", "current", "pending", "warning"); - node.classList.add(allowedStepStates.has(step.state) ? step.state : "pending"); - const number = node.querySelector("span"); - const label = node.querySelector("strong"); - if (number) number.textContent = step.index; - if (label) label.textContent = step.name; - }); - - renderRuntimeLog(data); - renderTaskLiveActions(data); - if (autoPipelineMonitor) { - autoPipelineMonitor.dataset.status = data.status || ""; - autoPipelineMonitor.dataset.running = data.should_poll ? "true" : "false"; - } - if (taskLiveNote) { - taskLiveNote.dataset.snapshotAt = data.snapshot_at || ""; - if (data.error_message) { - taskLiveNote.dataset.state = "error"; - taskLiveNote.textContent = `流程已暂停:${summarizeErrorMessage(data.error_message)}`; - } else if (data.should_poll) { - taskLiveNote.dataset.state = "active"; - taskLiveNote.textContent = `自动刷新中 · ${formatTaskLiveRefreshTime()} 已获取最新状态`; - } else { - taskLiveNote.dataset.state = "completed"; - taskLiveNote.textContent = `状态已更新 · ${formatTaskLiveRefreshTime()}`; - } - } -} - -function scheduleTaskLiveStatusPolling() { - if (!autoPipelineMonitor || document.hidden) return; - if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); - taskLiveStatusTimer = window.setTimeout(() => { - pollTaskLiveStatus().catch(() => {}); - }, TASK_LIVE_STATUS_INTERVAL_MS); -} - -async function pollTaskLiveStatus() { - if (!autoPipelineMonitor) return null; - if (taskLiveStatusRequestInFlight) return null; - const taskId = autoPipelineMonitor.dataset.taskId; - if (!taskId) return null; - if (taskLiveStatusTimer) { - window.clearTimeout(taskLiveStatusTimer); - taskLiveStatusTimer = null; - } - - taskLiveStatusRequestInFlight = true; - try { - const data = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/live-status`); - renderTaskLiveStatus(data); - if (data.should_poll) taskLiveForcedPollingUntil = 0; - if (data.should_poll || Date.now() < taskLiveForcedPollingUntil) { - scheduleTaskLiveStatusPolling(); - } - return data; - } catch (error) { - if (taskLiveNote) { - taskLiveNote.dataset.state = "error"; - taskLiveNote.textContent = `自动更新暂时中断,正在重试:${summarizeErrorMessage(error.message)}`; - } - scheduleTaskLiveStatusPolling(); - return null; - } finally { - taskLiveStatusRequestInFlight = false; - } -} - -function startTaskLiveStatusPolling(forceRestart = false) { - if (!autoPipelineMonitor) return; - if (forceRestart) taskLiveForcedPollingUntil = Date.now() + 10000; - pollTaskLiveStatus().catch(() => {}); -} - -if (autoPipelineMonitor) { - startTaskLiveStatusPolling(); - document.addEventListener("visibilitychange", () => { - if (document.hidden) { - if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); - taskLiveStatusTimer = null; - return; - } - startTaskLiveStatusPolling(); - }); -} - -async function pollAiAnalysisStatus(keepPolling = false) { - if (!aiAnalysisForm) return null; - const taskId = aiAnalysisForm.dataset.taskId; - const response = await fetch(`/api/tasks/${taskId}/ai-analysis-status`); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "读取 AI 分析状态失败"); - } - renderAiAnalysisProgress(data); - if (aiStatusPollingTimer) { - window.clearTimeout(aiStatusPollingTimer); - aiStatusPollingTimer = null; - } - if (keepPolling || data.is_running) { - aiStatusPollingTimer = window.setTimeout(() => { - pollAiAnalysisStatus(false).catch(() => {}); - }, 3000); - } - return data; -} - -function stopAiAnalysisStatusPolling() { - if (!aiStatusPollingTimer) return; - window.clearTimeout(aiStatusPollingTimer); - aiStatusPollingTimer = null; -} - -document.querySelectorAll("[data-render-subtitle]").forEach((button) => { - button.addEventListener("click", async () => { - const card = button.closest("[data-subtitle-output-card]"); - if (!card) return; - const taskId = card.dataset.taskId; - const outputId = card.dataset.outputId; - const statusNode = card.querySelector("[data-subtitle-status]"); - const errorNode = card.querySelector("[data-subtitle-error]"); - const originalText = button.textContent; - button.disabled = true; - button.textContent = "正在入队..."; - if (statusNode) statusNode.textContent = "字幕排队中"; - if (errorNode) errorNode.textContent = ""; - - try { - const response = await fetch(`/api/tasks/${taskId}/output-clips/${outputId}/subtitles`, { method: "POST" }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "自动加字幕失败"); - } - if (errorNode) errorNode.textContent = data.message || "字幕任务已加入队列。"; - button.textContent = "后台烧录中"; - await pollSubtitleWorkflowJob(data.job_id, statusNode, errorNode); - window.location.reload(); - } catch (error) { - if (statusNode) statusNode.textContent = "字幕失败"; - if (errorNode) errorNode.textContent = `自动加字幕失败:${error.message}`; - } finally { - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -async function pollSubtitleWorkflowJob(jobId, statusNode, messageNode) { - if (!jobId) throw new Error("后台没有返回字幕 job id"); - while (true) { - const response = await fetch(`/api/tasks/jobs/${encodeURIComponent(jobId)}`); - const job = await response.json(); - if (!response.ok) throw new Error(job.detail || "读取字幕任务状态失败"); - if (statusNode) statusNode.textContent = `${job.status_label || job.status} · ${Number(job.progress || 0)}%`; - if (messageNode) messageNode.textContent = job.message || "字幕任务处理中"; - if (job.status === "completed") return job; - if (job.status === "failed" || job.status === "cancelled") { - throw new Error(job.error_message || job.message || "字幕任务未完成"); - } - await new Promise((resolve) => window.setTimeout(resolve, 1500)); - } -} - -document.querySelectorAll("[data-live-subtitle-skip]").forEach((button) => { - button.addEventListener("click", async () => { - if (!window.confirm("确认跳过字幕并进入片段审核吗?审核保存后才会同步发送中心。")) return; - const taskId = button.dataset.taskId; - button.disabled = true; - const originalText = button.textContent; - button.textContent = "正在进入审核..."; - try { - const data = await apiFetch(`/api/subtitles/tasks/${encodeURIComponent(taskId)}/skip-to-review`, { - method: "POST", - }); - if (taskLiveNote) taskLiveNote.textContent = data.message || "已跳过字幕,正在进入片段审核"; - window.location.href = data.review_url || `/tasks/${encodeURIComponent(taskId)}/clips/review`; - } catch (error) { - window.alert(`跳过字幕失败:${summarizeErrorMessage(error.message)}`); - button.disabled = false; - button.textContent = originalText; - } - }); -}); - -const cutEditModal = document.querySelector("#cut-edit-modal"); -const closeCutEditButton = document.querySelector("#close-cut-edit"); -const cutEditVideo = document.querySelector("#cut-edit-video"); -const cutEditCaption = document.querySelector("#cut-edit-caption"); -const playCutPreviewButton = document.querySelector("#play-cut-preview"); -const saveCutEditButton = document.querySelector("#save-cut-edit"); -const cutEditSaveMessage = document.querySelector("#cut-edit-save-message"); -const trimFrameTrack = document.querySelector("#trim-frame-track"); -const trimSlider = document.querySelector("#trim-slider"); -const trimStripPlayButton = document.querySelector("#trim-strip-play"); -const trimPlayhead = document.querySelector("#trim-playhead"); -const trimStartInput = document.querySelector("#trim-start-input"); -const trimEndInput = document.querySelector("#trim-end-input"); -const trimDurationLabel = document.querySelector("#trim-duration-label"); -const trimStartPosition = document.querySelector("#trim-start-position"); -const trimCurrentPosition = document.querySelector("#trim-current-position"); -const trimEndPosition = document.querySelector("#trim-end-position"); -const setTrimStartButton = document.querySelector("#set-trim-start"); -const setTrimEndButton = document.querySelector("#set-trim-end"); -let activeTrimState = null; -let isSyncingTrimSlider = false; - -function setCutEditMessage(message, tone = "info") { - if (!cutEditSaveMessage) return; - cutEditSaveMessage.textContent = message; - cutEditSaveMessage.dataset.tone = tone; -} - -function getTrimDuration() { - if (!activeTrimState) return 1; - const videoDuration = Number(cutEditVideo?.duration || 0); - return Math.max(1, videoDuration || activeTrimState.durationSeconds || activeTrimState.endSeconds || 1); -} - -function clampTrimSeconds(value, min, max) { - return Math.max(min, Math.min(Number(value) || 0, max)); -} - -function normalizeTrimRange(startSeconds, endSeconds) { - const duration = getTrimDuration(); - const minGap = Math.min(1, Math.max(0.1, duration / 10)); - let start = clampTrimSeconds(startSeconds, 0, Math.max(0, duration - minGap)); - let end = clampTrimSeconds(endSeconds, start + minGap, duration); - if (end - start < minGap) { - if (end >= duration) { - start = Math.max(0, duration - minGap); - end = duration; - } else { - end = Math.min(duration, start + minGap); - } - } - return { start, end }; -} - -function getTrimSliderInstance() { - return trimSlider?.noUiSlider || null; -} - -function updateTrimPlayhead() { - if (!activeTrimState || !trimPlayhead || !trimFrameTrack) return; - const duration = getTrimDuration(); - const currentSeconds = clampTrimSeconds(cutEditVideo?.currentTime || activeTrimState.startSeconds, 0, duration); - const playWidth = trimStripPlayButton?.offsetWidth || 72; - const trackWidth = trimFrameTrack.clientWidth || 1; - const usableWidth = Math.max(1, trackWidth - playWidth); - trimPlayhead.style.left = `${playWidth + (currentSeconds / duration) * usableWidth}px`; -} - -function updateTrimReadout() { - if (!activeTrimState) return; - const currentSeconds = clampTrimSeconds(cutEditVideo?.currentTime || activeTrimState.startSeconds, 0, getTrimDuration()); - if (trimStartInput) trimStartInput.value = secondsToTimeText(activeTrimState.startSeconds); - if (trimEndInput) trimEndInput.value = secondsToTimeText(activeTrimState.endSeconds); - if (trimDurationLabel) trimDurationLabel.textContent = `选中 ${secondsToTimeText(activeTrimState.endSeconds - activeTrimState.startSeconds)}`; - if (trimStartPosition) trimStartPosition.textContent = secondsToTimeText(activeTrimState.startSeconds); - if (trimCurrentPosition) trimCurrentPosition.textContent = secondsToTimeText(currentSeconds); - if (trimEndPosition) trimEndPosition.textContent = secondsToTimeText(activeTrimState.endSeconds); - updateTrimPlayhead(); -} - -function handleTrimSliderUpdate(values, handle, unencoded) { - if (!activeTrimState || isSyncingTrimSlider) return; - const start = Number(unencoded?.[0] ?? values?.[0]); - const end = Number(unencoded?.[1] ?? values?.[1]); - if (!Number.isFinite(start) || !Number.isFinite(end)) return; - const normalized = normalizeTrimRange(start, end); - activeTrimState.startSeconds = Math.round(normalized.start); - activeTrimState.endSeconds = Math.round(normalized.end); - updateTrimReadout(); -} - -function syncTrimSliderOptions() { - if (!activeTrimState || !trimSlider) return false; - if (!window.noUiSlider) { - trimSlider.dataset.disabled = "true"; - setCutEditMessage("剪切滑块组件加载失败,请刷新页面后再试。", "error"); - return false; - } - - const duration = getTrimDuration(); - const normalized = normalizeTrimRange(activeTrimState.startSeconds, activeTrimState.endSeconds); - activeTrimState.startSeconds = Math.round(normalized.start); - activeTrimState.endSeconds = Math.round(normalized.end); - activeTrimState.durationSeconds = duration; - trimSlider.dataset.disabled = "false"; - - const options = { - start: [activeTrimState.startSeconds, activeTrimState.endSeconds], - connect: [false, true, false], - behaviour: "tap-drag", - step: 1, - margin: Math.min(1, Math.max(0.1, duration / 10)), - range: { - min: 0, - max: duration, - }, - }; - - isSyncingTrimSlider = true; - if (trimSlider.noUiSlider) { - trimSlider.noUiSlider.updateOptions(options, false); - } else { - window.noUiSlider.create(trimSlider, options); - trimSlider.noUiSlider.on("update", handleTrimSliderUpdate); - trimSlider.noUiSlider.on("slide", (values, handle, unencoded) => { - if (!cutEditVideo || !activeTrimState) return; - const nextTime = Number(unencoded?.[handle] ?? values?.[handle]); - if (Number.isFinite(nextTime)) cutEditVideo.currentTime = clampTrimSeconds(nextTime, 0, getTrimDuration()); - }); - trimSlider.noUiSlider.on("change", () => { - setCutEditMessage("已更新这个片段里的入点 / 出点,点击保存后会写回片段审核。", "success"); - }); - } - trimSlider.noUiSlider.set([activeTrimState.startSeconds, activeTrimState.endSeconds]); - isSyncingTrimSlider = false; - updateTrimReadout(); - return true; -} - -function renderTrimEditor() { - syncTrimSliderOptions(); -} - -function updateTrimRange(startSeconds, endSeconds, seekTo = "start") { - if (!activeTrimState) return; - const normalized = normalizeTrimRange(startSeconds, endSeconds); - activeTrimState.startSeconds = Math.round(normalized.start); - activeTrimState.endSeconds = Math.round(normalized.end); - const slider = getTrimSliderInstance(); - if (slider) slider.set([activeTrimState.startSeconds, activeTrimState.endSeconds]); - updateTrimReadout(); - if (cutEditVideo) { - cutEditVideo.currentTime = seekTo === "end" ? activeTrimState.endSeconds : activeTrimState.startSeconds; - } -} - -function toggleCutEditModal(show) { - if (!cutEditModal) return; - if (show) { - cutEditModal.removeAttribute("hidden"); - document.body.style.overflow = "hidden"; - return; - } - cutEditModal.setAttribute("hidden", ""); - document.body.style.overflow = ""; - if (cutEditVideo) cutEditVideo.pause(); - activeTrimState = null; - isSyncingTrimSlider = false; -} - -document.querySelectorAll("[data-cut-edit-trigger]").forEach((button) => { - button.addEventListener("click", () => { - const card = button.closest("[data-subtitle-output-card]"); - const clipVideo = card?.querySelector(":scope > video"); - if (!card || !cutEditVideo || !clipVideo) return; - const absoluteStartSeconds = Number(card.dataset.startSeconds || 0); - const absoluteEndSeconds = Number(card.dataset.endSeconds || absoluteStartSeconds + 1); - const outputBaseStartSeconds = Number(card.dataset.outputBaseStartSeconds || card.dataset.startSeconds || 0); - const fallbackDuration = Math.max( - 1, - Number(card.dataset.durationSeconds || 0), - Number(clipVideo.duration || 0), - absoluteEndSeconds - outputBaseStartSeconds - ); - const localStartSeconds = clampTrimSeconds(absoluteStartSeconds - outputBaseStartSeconds, 0, fallbackDuration); - const localEndSeconds = clampTrimSeconds(absoluteEndSeconds - outputBaseStartSeconds, localStartSeconds + 1, fallbackDuration); - activeTrimState = { - card, - taskId: card.dataset.taskId || "", - clipId: card.dataset.clipId || "", - title: card.dataset.clipTitle || card.querySelector(".subtitle-output-body strong")?.textContent || "当前切片", - summary: card.dataset.clipSummary || "", - enabled: card.dataset.clipEnabled !== "0", - outputBaseStartSeconds, - durationSeconds: fallbackDuration, - startSeconds: localStartSeconds, - endSeconds: localEndSeconds, - }; - cutEditVideo.src = card.dataset.outputMediaUrl || clipVideo.currentSrc || clipVideo.src; - cutEditVideo.load(); - if (cutEditCaption) { - cutEditCaption.textContent = activeTrimState.title; - } - setCutEditMessage("当前只裁这个已生成片段:拖动黄色左右把手调整入点和出点。", "info"); - toggleCutEditModal(true); - renderTrimEditor(); - cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); - }); -}); - -if (closeCutEditButton) { - closeCutEditButton.addEventListener("click", () => toggleCutEditModal(false)); -} - -if (cutEditModal) { - cutEditModal.addEventListener("click", (event) => { - if (event.target === cutEditModal) toggleCutEditModal(false); - }); -} - -if (playCutPreviewButton && cutEditVideo) { - playCutPreviewButton.addEventListener("click", () => { - if (activeTrimState) { - cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); - } - cutEditVideo.play().catch(() => {}); - }); -} - -if (trimStripPlayButton && cutEditVideo) { - trimStripPlayButton.addEventListener("click", () => { - if (activeTrimState) { - cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); - } - cutEditVideo.play().catch(() => {}); - }); -} - -if (cutEditVideo) { - cutEditVideo.addEventListener("loadedmetadata", () => { - if (!activeTrimState) return; - activeTrimState.durationSeconds = getTrimDuration(); - const normalized = normalizeTrimRange(activeTrimState.startSeconds, activeTrimState.endSeconds); - activeTrimState.startSeconds = Math.round(normalized.start); - activeTrimState.endSeconds = Math.round(normalized.end); - cutEditVideo.currentTime = activeTrimState.startSeconds; - renderTrimEditor(); - }); - cutEditVideo.addEventListener("timeupdate", () => { - if (!activeTrimState) return; - updateTrimReadout(); - if (cutEditVideo.currentTime >= activeTrimState.endSeconds) { - cutEditVideo.pause(); - cutEditVideo.currentTime = activeTrimState.startSeconds; - } - }); -} - -if (trimStartInput) { - trimStartInput.addEventListener("change", () => { - updateTrimRange(timeTextToSeconds(trimStartInput.value), activeTrimState?.endSeconds || 1, "start"); - }); -} - -if (trimEndInput) { - trimEndInput.addEventListener("change", () => { - updateTrimRange(activeTrimState?.startSeconds || 0, timeTextToSeconds(trimEndInput.value), "end"); - }); -} - -if (setTrimStartButton && cutEditVideo) { - setTrimStartButton.addEventListener("click", () => { - updateTrimRange(cutEditVideo.currentTime, activeTrimState?.endSeconds || cutEditVideo.currentTime + 1, "start"); - }); -} - -if (setTrimEndButton && cutEditVideo) { - setTrimEndButton.addEventListener("click", () => { - updateTrimRange(activeTrimState?.startSeconds || 0, cutEditVideo.currentTime, "end"); - }); -} - -if (saveCutEditButton) { - saveCutEditButton.addEventListener("click", async () => { - if (!activeTrimState?.taskId || !activeTrimState?.clipId) { - setCutEditMessage("这条切片没有关联到候选片段,不能直接保存时间。", "error"); - return; - } - const originalText = saveCutEditButton.textContent; - saveCutEditButton.disabled = true; - saveCutEditButton.textContent = "保存中..."; - setCutEditMessage("正在保存这个片段内的入点和出点...", "info"); - const absoluteStartSeconds = Math.round(activeTrimState.outputBaseStartSeconds + activeTrimState.startSeconds); - const absoluteEndSeconds = Math.round(activeTrimState.outputBaseStartSeconds + activeTrimState.endSeconds); - try { - const response = await fetch(`/api/tasks/${activeTrimState.taskId}/clips/${activeTrimState.clipId}/update`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: activeTrimState.title, - start_time: secondsToTimeText(absoluteStartSeconds), - end_time: secondsToTimeText(absoluteEndSeconds), - enabled: activeTrimState.enabled, - summary: activeTrimState.summary, - }), - }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.detail || "保存失败"); - } - activeTrimState.card.dataset.startSeconds = String(absoluteStartSeconds); - activeTrimState.card.dataset.endSeconds = String(absoluteEndSeconds); - activeTrimState.card.dataset.durationSeconds = String(Math.max(1, absoluteEndSeconds - absoluteStartSeconds)); - activeTrimState.card.dataset.startTime = secondsToTimeText(absoluteStartSeconds); - activeTrimState.card.dataset.endTime = secondsToTimeText(absoluteEndSeconds); - setCutEditMessage("已保存。回到片段审核页重新生成切片后,会得到新的视频文件。", "success"); - } catch (error) { - setCutEditMessage(`保存失败:${error.message}`, "error"); - } finally { - saveCutEditButton.disabled = false; - saveCutEditButton.textContent = originalText; - } - }); -} - -window.addEventListener("resize", () => { - if (activeTrimState) updateTrimPlayhead(); -}); - -document.querySelectorAll(".js-demo-toast").forEach((button) => { - button.addEventListener("click", () => { - window.alert(button.dataset.message || "这个功能已经预留入口,后续会继续接入。"); - }); -}); - -const aiConfigModal = document.querySelector("#ai-config-modal"); -const aiConfigForm = document.querySelector("#ai-config-form"); -const aiConfigResult = document.querySelector("#ai-config-result"); -const openAiConfigButton = document.querySelector("#open-ai-config"); -const closeAiConfigButton = document.querySelector("#close-ai-config"); -const cancelAiConfigButton = document.querySelector("#cancel-ai-config"); - -function toggleAiConfigModal(show) { - if (!aiConfigModal) return; - if (show) { - aiConfigModal.removeAttribute("hidden"); - document.body.style.overflow = "hidden"; - closeAiConfigButton?.focus(); - return; - } - aiConfigModal.setAttribute("hidden", ""); - document.body.style.overflow = ""; -} - -if (openAiConfigButton) { - openAiConfigButton.addEventListener("click", () => toggleAiConfigModal(true)); -} - -[closeAiConfigButton, cancelAiConfigButton].forEach((button) => { - if (!button) return; - button.addEventListener("click", () => toggleAiConfigModal(false)); -}); - -if (aiConfigModal) { - aiConfigModal.addEventListener("click", (event) => { - if (event.target === aiConfigModal) { - toggleAiConfigModal(false); - } - }); -} - -document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && aiConfigModal && !aiConfigModal.hidden) { - toggleAiConfigModal(false); - } -}); - -if (aiConfigForm) { - aiConfigForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const submitButton = aiConfigForm.querySelector("button[type='submit']"); - const formData = new FormData(aiConfigForm); - const payload = Object.fromEntries(formData.entries()); - payload.ai_request_timeout_seconds = Number(payload.ai_request_timeout_seconds || 120); - payload.ai_codex_timeout_seconds = Number(payload.ai_codex_timeout_seconds || 600); - payload.volcengine_asr_timeout_seconds = Number(payload.volcengine_asr_timeout_seconds || 300); - payload.ai_analysis_request_timeout_seconds = Number(payload.ai_analysis_request_timeout_seconds || 120); - payload.ai_publish_request_timeout_seconds = Number(payload.ai_publish_request_timeout_seconds || 120); - payload.ai_local_health_timeout_seconds = Number(payload.ai_local_health_timeout_seconds || 30); - - submitButton.disabled = true; - if (aiConfigResult) aiConfigResult.textContent = "正在保存三类 AI 接口配置..."; - - try { - const data = await window.apiFetch("/api/settings/ai", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (aiConfigResult) aiConfigResult.textContent = data.message || "保存成功。"; - window.setTimeout(() => { - toggleAiConfigModal(false); - window.location.reload(); - }, 500); - } catch (error) { - if (aiConfigResult) aiConfigResult.textContent = `保存失败:${error.message}`; - } finally { - submitButton.disabled = false; - } - }); -} - -// 发送中心页面行为由 publish-center.js 独立维护,避免全局脚本重复绑定。 +// Compatibility entry point: load only the behavior needed by this page. +(() => { + const entry = document.currentScript; + const root = new URL(".", entry.src); + const version = new URL(entry.src).search; + const scripts = ["workspace-helpers.js"]; + if (document.querySelector("#new-task-form")) scripts.push("new-task.js"); + if (document.querySelector("#ai-analysis-form, #transcript-panel, [data-auto-pipeline-monitor], .js-process-action")) scripts.push("task-progress.js"); + if (document.querySelector("#clip-review-form")) scripts.push("clip-workspace.js", "review-workspace.js"); + if (document.querySelector("#subtitle-style-form, #subtitle-editor, [data-subtitle-output-card]")) scripts.push("subtitle-workflow.js"); + if (document.querySelector("#ai-config-form")) scripts.push("system-settings.js"); + scripts.push("task-actions.js"); + for (const file of scripts) { + const preload = document.createElement("link"); + preload.rel = "preload"; preload.as = "script"; + preload.href = new URL(file, root).href + version; + document.head.append(preload); + } + window.studioPageReady = scripts.reduce((ready, file) => ready.then(() => new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = new URL(file, root).href + version; + script.onload = resolve; + script.onerror = () => reject(new Error("页面交互加载失败:" + file)); + document.body.append(script); + })), Promise.resolve()).catch(error => { + const alert = document.createElement("p"); + alert.className = "page-alert"; + alert.setAttribute("role", "alert"); + alert.textContent = error.message + ",请刷新页面重试。"; + document.querySelector("main")?.prepend(alert); + }); +})(); diff --git a/app/static/js/clip-workspace.js b/app/static/js/clip-workspace.js new file mode 100644 index 0000000..14d854e --- /dev/null +++ b/app/static/js/clip-workspace.js @@ -0,0 +1,844 @@ +const clipFilterForm = document.querySelector("#clip-filter-form"); +if (clipFilterForm) { + clipFilterForm.querySelectorAll("select").forEach((select) => { + select.addEventListener("change", () => clipFilterForm.submit()); + }); +} + +const clipReviewForm = document.querySelector("#clip-review-form"); +const saveClipsButton = document.querySelector("#save-clips-button"); +const generateClipsButton = document.querySelector("#generate-clips-button"); +const clipReviewMessage = document.querySelector("#clip-review-message"); +const clipSelectAll = document.querySelector("[data-clip-select-all]"); +const clipSelectAllLabel = document.querySelector("[data-clip-select-all-label]"); +const clipSelectCount = document.querySelector("[data-clip-select-count]"); +const syncReviewedClipsButton = document.querySelector("[data-sync-reviewed-clips='true']"); +const cutJobProgress = document.querySelector("#cut-job-progress"); +const cutJobStatus = document.querySelector("#cut-job-status"); +const cutJobPercent = document.querySelector("#cut-job-percent"); +const cutJobProgressBar = document.querySelector("#cut-job-progress-bar"); +const cutJobMessage = document.querySelector("#cut-job-message"); +const clipPreviewVideo = document.querySelector("#clip-preview-video"); +const clipPreviewDock = document.querySelector("#clip-preview-dock"); +const clipPreviewCaption = document.querySelector("#clip-preview-caption"); +const clipTranscriptDrawer = document.querySelector("#clip-transcript-drawer"); +const clipTranscriptTitle = document.querySelector("#clip-transcript-title"); +const clipTranscriptTime = document.querySelector("#clip-transcript-time"); +const clipTranscriptBody = document.querySelector("#clip-transcript-body"); +const closeTranscriptDrawerButton = document.querySelector("#close-transcript-drawer"); +const sourceMonitorModal = document.querySelector("#source-monitor-modal"); +const closeSourceMonitorButton = document.querySelector("#close-source-monitor"); +const cancelSourceMonitorButton = document.querySelector("#cancel-source-monitor"); +const applySourceMonitorButton = document.querySelector("#apply-source-monitor"); +const sourceMonitorVideo = document.querySelector("#source-monitor-video"); +const sourceMonitorTrack = document.querySelector("#source-monitor-track"); +const sourceMonitorSlider = document.querySelector("#source-monitor-slider"); +const sourceMonitorPlayhead = document.querySelector("#source-monitor-playhead"); +const sourceMonitorCurrent = document.querySelector("#source-monitor-current"); +const sourceMonitorDuration = document.querySelector("#source-monitor-duration"); +const sourceMonitorZoom = document.querySelector("#source-monitor-zoom"); +const sourceMonitorWindowStart = document.querySelector("#source-monitor-window-start"); +const sourceMonitorWindowEnd = document.querySelector("#source-monitor-window-end"); +const sourceMonitorInTime = document.querySelector("#source-monitor-in-time"); +const sourceMonitorOutTime = document.querySelector("#source-monitor-out-time"); +const sourceMonitorMessage = document.querySelector("#source-monitor-message"); +let activePreviewEndSeconds = null; +let activeSourceMonitor = null; +let isSyncingSourceSlider = false; +let isCutJobActive = false; +let isClipSaveActive = false; +let isReviewHandoffActive = false; +let savedClipReviewPayload = clipReviewForm ? collectClipReviewPayload() : []; +let savedEnabledClipCount = Number(clipReviewForm?.dataset.enabledCount || 0); + +const cutJobStatusLabels = { + queued: "排队中", + running: "运行中", + completed: "已完成", + failed: "失败", + cancelled: "已取消", +}; + +function renderCutJobProgress(job, fallbackMessage = "") { + if (!cutJobProgress || !job) return; + const status = job.status || "queued"; + const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); + cutJobProgress.hidden = false; + cutJobProgress.dataset.status = status; + if (cutJobStatus) { + cutJobStatus.textContent = `${status}(${cutJobStatusLabels[status] || "处理中"})`; + } + if (cutJobPercent) cutJobPercent.textContent = `${progress}%`; + if (cutJobProgressBar) cutJobProgressBar.style.width = `${progress}%`; + if (cutJobMessage) { + cutJobMessage.textContent = status === "failed" + ? (job.error_message || job.message || "切片任务失败") + : (job.queue_hint || job.message || fallbackMessage || "正在等待切片任务更新..."); + } +} + + +async function waitForCutJob(jobId) { + while (true) { + const response = await fetch(`/api/tasks/jobs/${jobId}`); + const job = await response.json(); + if (!response.ok) { + throw new Error(job.detail || "查询切片任务进度失败"); + } + renderCutJobProgress(job); + if (job.status === "completed") return job; + if (job.status === "failed" || job.status === "cancelled") { + throw new Error(job.error_message || job.message || "切片任务未完成"); + } + await wait(1000); + } +} + +async function showCompletedCut(jobId) { + const completedJob = await waitForCutJob(jobId); + const syncMessage = completedJob.result_json?.publish_sync?.message + ? ` ${completedJob.result_json.publish_sync.message}` : ""; + showClipReviewMessage( + `${completedJob.result_json?.message || completedJob.message || "切片生成完成。"}${syncMessage} 正在刷新切片结果...`, + completedJob.result_json?.publish_sync?.status === "partial" ? "error" : "success", + ); + window.setTimeout(() => window.location.reload(), 700); +} + +async function restoreCutProgress() { + if (!generateClipsButton || !clipReviewForm) return; + isCutJobActive = true; + updateClipReviewActionState(); + const originalText = generateClipsButton.textContent; + try { + const response = await fetch(`/api/tasks/${clipReviewForm.dataset.taskId}/jobs`); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "读取切片进度失败"); + const job = (data.jobs || []).find(item => item.job_type === "video_cut" && ["queued", "running"].includes(item.status)); + if (job) { + generateClipsButton.textContent = "切片处理中..."; + renderCutJobProgress(job); + showClipReviewMessage("切片正在后台继续,可以离开页面处理其他任务。", "info"); + await showCompletedCut(job.id); + } + } catch (error) { + showClipReviewMessage(`读取切片进度失败:${error.message}`, "error"); + } finally { + isCutJobActive = false; + updateClipReviewActionState(); + generateClipsButton.textContent = originalText; + } +} + +function getClipReviewCards() { + return Array.from(document.querySelectorAll("[data-clip-card]")); +} + +function getClipEnableCheckboxes() { + return getClipReviewCards() + .map((card) => card.querySelector("[name='enabled']")) + .filter(Boolean); +} + +function updateClipSelectAllUi() { + const checkboxes = getClipEnableCheckboxes(); + const enabledCount = checkboxes.filter((checkbox) => checkbox.checked).length; + const allEnabled = checkboxes.length > 0 && enabledCount === checkboxes.length; + if (clipSelectAll) { + clipSelectAll.disabled = checkboxes.length === 0 || isCutJobActive; + clipSelectAll.checked = allEnabled; + clipSelectAll.indeterminate = enabledCount > 0 && !allEnabled; + } + if (clipSelectAllLabel) clipSelectAllLabel.textContent = allEnabled ? "取消全选" : "全选当前列表"; + if (clipSelectCount) clipSelectCount.textContent = `已启用 ${enabledCount} / ${checkboxes.length} 条`; +} + +function updateClipReviewActionState() { + const hasCards = getClipReviewCards().length > 0; + const busy = isCutJobActive || isClipSaveActive || isReviewHandoffActive; + const payload = clipReviewForm ? collectClipReviewPayload() : []; + const dirty = JSON.stringify(payload) !== JSON.stringify(savedClipReviewPayload); + const enabledCount = savedEnabledClipCount - savedClipReviewPayload.filter(item => item.enabled).length + + payload.filter(item => item.enabled).length; + if (saveClipsButton) saveClipsButton.disabled = !hasCards || busy; + if (generateClipsButton) generateClipsButton.disabled = !hasCards || busy || enabledCount === 0; + if (syncReviewedClipsButton) syncReviewedClipsButton.disabled = !hasCards || busy; + clipReviewForm?.querySelectorAll("input, textarea, [data-delete-trigger], [data-source-monitor-trigger], [data-reject-reason]").forEach(node => { node.disabled = busy; }); + clipFilterForm?.querySelectorAll("select").forEach(node => { node.disabled = busy || dirty; }); + updateClipSelectAllUi(); + if (clipSelectAll) clipSelectAll.disabled = !hasCards || busy; + if (clipReviewForm) { + Object.assign(clipReviewForm.dataset, {dirty: String(dirty), busy: String(busy), enabledCount: String(enabledCount)}); + document.dispatchEvent(new CustomEvent("clip-review-state")); + } +} + +document.addEventListener("production-review-busy", event => { + isReviewHandoffActive = event.detail.busy; + updateClipReviewActionState(); +}); + +function collectClipReviewPayload() { + const cards = getClipReviewCards(); + return cards.map((card) => { + const enabled = card.querySelector("[name='enabled']").checked; + return { + id: card.dataset.clipId, + title: card.querySelector("[name='title']").value.trim(), + start_time: card.querySelector("[name='start_time']").value.trim(), + end_time: card.querySelector("[name='end_time']").value.trim(), + enabled, + summary: card.querySelector("[name='summary']").value.trim(), + feedback_reason_code: enabled ? null : (card.dataset.feedbackReason || null), + }; + }); +} + +async function persistClipReviewChanges() { + if (!clipReviewForm) throw new Error("当前页面没有可保存的候选片段"); + const taskId = clipReviewForm.dataset.taskId; + const payload = collectClipReviewPayload(); + const response = await fetch(`/api/tasks/${taskId}/clips/batch-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips: payload }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "保存失败"); + savedClipReviewPayload = payload; + savedEnabledClipCount = data.clips.filter(item => item.enabled).length; + updateClipReviewActionState(); + document.dispatchEvent(new CustomEvent("clip-review-saved")); + return data; +} + +async function deleteClipCard(card, button) { + if (!clipReviewForm || !card || isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; + const confirmed = await window.StudioUI.confirm({title: "删除候选片段", message: "删除后本任务不再使用这个候选片段,原始素材保留。确认删除当前候选吗?", confirmLabel: "删除候选", danger: true}); + if (!confirmed) return; + const taskId = clipReviewForm.dataset.taskId; + const clipId = card.dataset.clipId; + const originalText = button?.textContent || ""; + isClipSaveActive = true; + updateClipReviewActionState(); + if (button) { + button.disabled = true; + button.textContent = "\u5220\u9664\u4e2d..."; + } + card.classList.add("is-removing"); + showClipReviewMessage("\u6b63\u5728\u5220\u9664\u8fd9\u6761\u5019\u9009\u7247\u6bb5...", "info"); + + try { + const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}`, { method: "DELETE" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "\u5220\u9664\u5931\u8d25"); + } + if (activeSourceMonitor?.card === card) { + toggleSourceMonitor(false); + } + savedEnabledClipCount -= Number(!!savedClipReviewPayload.find(item => item.id === clipId)?.enabled); + savedClipReviewPayload = savedClipReviewPayload.filter(item => item.id !== clipId); + card.remove(); + updateClipReviewActionState(); + document.dispatchEvent(new CustomEvent("clip-review-saved")); + closeTranscriptDrawer(); + showClipReviewMessage( + data.message || "\u5df2\u5220\u9664\u8be5\u5019\u9009\u7247\u6bb5\uff0c\u540e\u7eed\u751f\u6210\u5207\u7247\u4e0d\u4f1a\u518d\u4f7f\u7528\u5b83\u3002", + "success", + ); + } catch (error) { + card.classList.remove("is-removing"); + if (button) { + button.disabled = false; + button.textContent = originalText; + } + showClipReviewMessage(`\u5220\u9664\u5931\u8d25\uff1a${error.message}`, "error"); + } finally { + isClipSaveActive = false; + updateClipReviewActionState(); + } +} + +function syncRejectReasonVisibility(card) { + if (!card) return; + const enabledInput = card.querySelector("[name='enabled']"); + const rejectFeedback = card.querySelector("[data-reject-feedback]"); + if (rejectFeedback && enabledInput) rejectFeedback.hidden = enabledInput.checked; +} + + +function updateCardTimeDataset(card) { + if (!card) return; + card.dataset.startSeconds = String(timeTextToSeconds(card.querySelector("[name='start_time']")?.value)); + card.dataset.endSeconds = String(timeTextToSeconds(card.querySelector("[name='end_time']")?.value)); +} + +function setSourceMonitorMessage(message, tone = "info") { + if (!sourceMonitorMessage) return; + sourceMonitorMessage.textContent = message; + sourceMonitorMessage.dataset.tone = tone; +} + +function setSourceMonitorControlsDisabled(disabled) { + document.querySelectorAll("[data-source-action], [data-source-step]").forEach((button) => { + button.disabled = disabled; + }); + if (applySourceMonitorButton) applySourceMonitorButton.disabled = disabled; +} + +function getReviewMaxClipSeconds() { + const value = Number(clipReviewForm?.dataset.maxClipSeconds || 0); + return Number.isFinite(value) && value > 0 ? value : 300; +} + +function getSourceVideoDuration() { + const videoDuration = Number(sourceMonitorVideo?.duration || 0); + if (Number.isFinite(videoDuration) && videoDuration > 0) return videoDuration; + if (!activeSourceMonitor) return 1; + return Math.max(activeSourceMonitor.endSeconds + 60, activeSourceMonitor.startSeconds + 61); +} + +function clampSeconds(value, min, max) { + return Math.max(min, Math.min(Number(value) || 0, max)); +} + +function getSourceWindowRange() { + if (!activeSourceMonitor) return { start: 0, end: 1, span: 1 }; + const duration = getSourceVideoDuration(); + const selected = Math.max(1, activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds); + const midpoint = (activeSourceMonitor.startSeconds + activeSourceMonitor.endSeconds) / 2; + const zoom = sourceMonitorZoom?.value || "fit"; + const spanMap = { + fit: Math.max(120, selected * 4), + half: Math.max(60, selected * 2), + quarter: Math.max(30, selected * 1.25), + }; + const span = Math.min(duration, spanMap[zoom] || spanMap.fit); + const start = clampSeconds(midpoint - span / 2, 0, Math.max(0, duration - span)); + return { start, end: start + span, span }; +} + +function normalizeSourceRange(startSeconds, endSeconds, anchor = "end") { + const duration = getSourceVideoDuration(); + const maxClipSeconds = getReviewMaxClipSeconds(); + const minGap = 1; + let start = clampSeconds(startSeconds, 0, Math.max(0, duration - minGap)); + let end = clampSeconds(endSeconds, start + minGap, duration); + let adjusted = false; + + if (end - start > maxClipSeconds) { + adjusted = true; + if (anchor === "start") { + end = Math.min(duration, start + maxClipSeconds); + } else if (anchor === "end") { + start = Math.max(0, end - maxClipSeconds); + } else { + end = Math.min(duration, start + maxClipSeconds); + } + } + + if (end <= start) { + adjusted = true; + if (anchor === "end") { + start = Math.max(0, end - minGap); + } else { + end = Math.min(duration, start + minGap); + } + } + + return { start, end, adjusted }; +} + +function updateSourceMonitorReadout(view = getSourceWindowRange()) { + if (!activeSourceMonitor) return; + const duration = getSourceVideoDuration(); + const currentSeconds = Number(sourceMonitorVideo?.currentTime || activeSourceMonitor.startSeconds); + if (sourceMonitorCurrent) sourceMonitorCurrent.textContent = formatTimecode(currentSeconds); + if (sourceMonitorDuration) sourceMonitorDuration.textContent = `片段 ${secondsToTimeText(activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds)}`; + if (sourceMonitorWindowStart) sourceMonitorWindowStart.textContent = secondsToTimeText(view.start); + if (sourceMonitorWindowEnd) sourceMonitorWindowEnd.textContent = secondsToTimeText(Math.min(duration, view.end)); + if (sourceMonitorInTime) sourceMonitorInTime.textContent = `入点 ${secondsToTimeText(activeSourceMonitor.startSeconds)}`; + if (sourceMonitorOutTime) sourceMonitorOutTime.textContent = `出点 ${secondsToTimeText(activeSourceMonitor.endSeconds)}`; +} + +function updateSourceMonitorPlayhead(view = getSourceWindowRange()) { + if (!activeSourceMonitor || !sourceMonitorPlayhead) return; + const currentSeconds = Number(sourceMonitorVideo?.currentTime || activeSourceMonitor.startSeconds); + const ratio = (clampSeconds(currentSeconds, view.start, view.end) - view.start) / Math.max(1, view.span); + const leftPadding = 18; + const rightPadding = 18; + const trackWidth = sourceMonitorTrack?.clientWidth || 1; + const usableWidth = Math.max(1, trackWidth - leftPadding - rightPadding); + sourceMonitorPlayhead.style.left = `${leftPadding + ratio * usableWidth}px`; +} + +function handleSourceSliderUpdate(values, handle, unencoded) { + if (!activeSourceMonitor || isSyncingSourceSlider) return; + const start = Number(unencoded?.[0] ?? values?.[0]); + const end = Number(unencoded?.[1] ?? values?.[1]); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + activeSourceMonitor.startSeconds = Math.round(start); + activeSourceMonitor.endSeconds = Math.round(end); + updateSourceMonitorReadout(); + updateSourceMonitorPlayhead(); +} + +function syncSourceSliderOptions(view = getSourceWindowRange()) { + if (!activeSourceMonitor || !sourceMonitorSlider) return false; + if (!window.noUiSlider) { + sourceMonitorSlider.dataset.disabled = "true"; + setSourceMonitorMessage("剪辑滑块组件加载失败,请刷新页面后再试。", "error"); + setSourceMonitorControlsDisabled(true); + return false; + } + sourceMonitorSlider.dataset.disabled = "false"; + setSourceMonitorControlsDisabled(false); + + const options = { + start: [activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds], + connect: [false, true, false], + behaviour: "tap-drag", + step: Number(activeSourceMonitor.stepSeconds || 1), + margin: 1, + limit: getReviewMaxClipSeconds(), + range: { + min: view.start, + max: Math.max(view.start + 1, view.end), + }, + pips: { + mode: "count", + values: 9, + density: 4, + }, + }; + + isSyncingSourceSlider = true; + if (sourceMonitorSlider.noUiSlider) { + sourceMonitorSlider.noUiSlider.updateOptions(options, false); + } else { + window.noUiSlider.create(sourceMonitorSlider, options); + sourceMonitorSlider.noUiSlider.on("update", handleSourceSliderUpdate); + sourceMonitorSlider.noUiSlider.on("slide", (values, handle, unencoded) => { + if (!sourceMonitorVideo || !activeSourceMonitor) return; + const nextTime = Number(unencoded?.[handle] ?? values?.[handle]); + if (Number.isFinite(nextTime)) sourceMonitorVideo.currentTime = clampSeconds(nextTime, 0, getSourceVideoDuration()); + }); + sourceMonitorSlider.noUiSlider.on("change", () => { + setSourceMonitorMessage("已更新入点 / 出点。点击“应用到片段”后,再保存修改。", "success"); + }); + } + sourceMonitorSlider.noUiSlider.set([activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds]); + isSyncingSourceSlider = false; + return true; +} + +function renderSourceMonitor() { + if (!activeSourceMonitor) return; + const view = getSourceWindowRange(); + syncSourceSliderOptions(view); + updateSourceMonitorReadout(view); + updateSourceMonitorPlayhead(view); +} + +function updateSourceMonitorRange(startSeconds, endSeconds, anchor = "end", seekTo = null) { + if (!activeSourceMonitor) return; + const normalized = normalizeSourceRange(startSeconds, endSeconds, anchor); + activeSourceMonitor.startSeconds = normalized.start; + activeSourceMonitor.endSeconds = normalized.end; + renderSourceMonitor(); + if (sourceMonitorVideo && seekTo) { + sourceMonitorVideo.currentTime = seekTo === "out" ? activeSourceMonitor.endSeconds : activeSourceMonitor.startSeconds; + } + if (normalized.adjusted) { + setSourceMonitorMessage(`已按任务限制自动收紧范围,单条最长 ${Math.round(getReviewMaxClipSeconds() / 60)} 分钟。`, "info"); + } +} + +function setSourceMonitorTime(seconds) { + if (!sourceMonitorVideo) return; + sourceMonitorVideo.currentTime = clampSeconds(seconds, 0, getSourceVideoDuration()); + renderSourceMonitor(); +} + +function toggleSourceMonitor(show) { + if (!sourceMonitorModal) return; + if (show) { + sourceMonitorModal.removeAttribute("hidden"); + document.body.style.overflow = "hidden"; + return; + } + sourceMonitorModal.setAttribute("hidden", ""); + document.body.style.overflow = ""; + activeSourceMonitor = null; + isSyncingSourceSlider = false; + if (sourceMonitorVideo) sourceMonitorVideo.pause(); +} + +function openSourceMonitor(card) { + if (!card || !sourceMonitorVideo) { + showClipReviewMessage("没有找到源视频,暂时无法打开源监视器。", "error"); + return; + } + updateCardTimeDataset(card); + const startSeconds = Number(card.dataset.startSeconds || 0); + const endSeconds = Number(card.dataset.endSeconds || startSeconds + 1); + activeSourceMonitor = { + card, + startSeconds, + endSeconds, + isPreviewing: false, + stepSeconds: 1, + }; + const normalized = normalizeSourceRange(startSeconds, endSeconds, "end"); + activeSourceMonitor = { + card, + startSeconds: normalized.start, + endSeconds: normalized.end, + isPreviewing: false, + stepSeconds: 1, + }; + sourceMonitorVideo.currentTime = activeSourceMonitor.startSeconds; + if (sourceMonitorZoom) sourceMonitorZoom.value = "fit"; + document.querySelectorAll("[data-source-step]").forEach((button) => { + button.classList.toggle("active", button.dataset.sourceStep === "1"); + }); + setSourceMonitorMessage("拖动蓝色范围左右手柄,或播放到合适位置后设置入点 / 出点。"); + toggleSourceMonitor(true); + renderSourceMonitor(); +} + +function applySourceMonitorToCard() { + if (!activeSourceMonitor?.card) return; + const card = activeSourceMonitor.card; + const startInput = card.querySelector("[name='start_time']"); + const endInput = card.querySelector("[name='end_time']"); + if (startInput) startInput.value = secondsToTimeText(activeSourceMonitor.startSeconds); + if (endInput) endInput.value = secondsToTimeText(activeSourceMonitor.endSeconds); + updateCardTimeDataset(card); + const durationPill = card.querySelector(".status-pill"); + if (durationPill) durationPill.textContent = `${Math.round(activeSourceMonitor.endSeconds - activeSourceMonitor.startSeconds)} 秒`; + showClipReviewMessage("已应用新的入点 / 出点。确认无误后,请点击右侧“保存修改”写入数据库。", "success"); + updateClipReviewActionState(); + toggleSourceMonitor(false); +} + +document.querySelectorAll("[data-clip-card] input[name='start_time'], [data-clip-card] input[name='end_time']").forEach((input) => { + input.addEventListener("change", () => updateCardTimeDataset(input.closest("[data-clip-card]"))); +}); + +if (clipSelectAll) { + clipSelectAll.addEventListener("change", () => { + const shouldEnable = clipSelectAll.checked; + getClipEnableCheckboxes().forEach((checkbox) => { + checkbox.checked = shouldEnable; + syncRejectReasonVisibility(checkbox.closest("[data-clip-card]")); + }); + updateClipReviewActionState(); + showClipReviewMessage( + shouldEnable + ? "已全选当前列表。确认无误后,请点击“保存修改”写入数据库。" + : "已取消当前列表的全部选择。确认无误后,请点击“保存修改”写入数据库。", + "info", + ); + }); +} + +clipReviewForm?.addEventListener("change", (event) => { + if (event.target.matches("[data-clip-card] input[name='enabled']")) { + syncRejectReasonVisibility(event.target.closest("[data-clip-card]")); + updateClipSelectAllUi(); + } + updateClipReviewActionState(); +}); +clipReviewForm?.addEventListener("input", (event) => { + // The bulk checkbox applies its selection in change; do not reset it before that event. + if (event.target.matches("[data-clip-card] input, [data-clip-card] textarea")) updateClipReviewActionState(); +}); + +updateClipSelectAllUi(); + +if (saveClipsButton && clipReviewForm) { + saveClipsButton.addEventListener("click", async () => { + if (isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; + const taskId = clipReviewForm.dataset.taskId; + const originalText = saveClipsButton.textContent; + isClipSaveActive = true; + updateClipReviewActionState(); + saveClipsButton.textContent = "正在保存..."; + showClipReviewMessage("正在保存候选片段修改...", "info"); + + try { + const data = await persistClipReviewChanges(); + showClipReviewMessage(data.message || "保存成功。", "success"); + } catch (error) { + showClipReviewMessage(`保存失败:${error.message}`, "error"); + } finally { + isClipSaveActive = false; + updateClipReviewActionState(); + saveClipsButton.textContent = originalText; + } + }); +} + +function playClipPreview(card) { + if (!clipPreviewVideo || !card) return; + updateCardTimeDataset(card); + const startSeconds = Number(card.dataset.startSeconds || 0); + const endSeconds = Number(card.dataset.endSeconds || 0); + activePreviewEndSeconds = Number.isFinite(endSeconds) && endSeconds > startSeconds ? endSeconds : null; + clipPreviewVideo.currentTime = Math.max(0, startSeconds); + clipPreviewVideo.play().catch(() => {}); + if (clipPreviewCaption) { + const title = card.dataset.title || "当前片段"; + clipPreviewCaption.textContent = `${title}:从 ${card.querySelector("[name='start_time']")?.value || ""} 播放到 ${card.querySelector("[name='end_time']")?.value || ""}`; + } + ensureClipPreviewVisible(); +} + +function ensureClipPreviewVisible() { + const previewTarget = clipPreviewDock || clipPreviewVideo; + if (!previewTarget) return; + const rect = previewTarget.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const isVisible = rect.top >= 0 && rect.top < viewportHeight * 0.72 && rect.bottom > Math.min(120, viewportHeight); + if (isVisible) return; + previewTarget.scrollIntoView({ behavior: preferredScrollBehavior(), block: "start", inline: "nearest" }); +} + +let excerptRequest = null; +function closeTranscriptDrawer() { + excerptRequest?.abort(); + excerptRequest = null; + if (!clipTranscriptDrawer) return; + clipTranscriptDrawer.hidden = true; + document.body.classList.remove("transcript-drawer-open"); +} + +function renderTranscriptRows(rows) { + if (!clipTranscriptBody) return; + clipTranscriptBody.replaceChildren(); + if (!rows.length) { + const empty = document.createElement("p"); + empty.className = "empty-note"; + empty.textContent = "这一段暂时没有匹配到逐句转写。可以先查看完整转写,或重新生成转写后再试。"; + clipTranscriptBody.append(empty); + return; + } + rows.forEach((row) => { + const item = document.createElement("article"); + item.className = "transcript-line"; + const time = document.createElement("time"); + time.textContent = `${row.start_time} - ${row.end_time}`; + const text = document.createElement("p"); + text.textContent = row.text; + item.append(time, text); + clipTranscriptBody.append(item); + }); +} + +async function openTranscriptDrawer(card) { + if (!clipTranscriptDrawer || !clipReviewForm || !card) return; + excerptRequest?.abort(); + const request = new AbortController(); + excerptRequest = request; + updateCardTimeDataset(card); + const taskId = clipReviewForm.dataset.taskId; + const clipId = card.dataset.clipId; + clipTranscriptDrawer.hidden = false; + document.body.classList.add("transcript-drawer-open"); + if (clipTranscriptTitle) clipTranscriptTitle.textContent = card.dataset.title || "片段转写"; + if (clipTranscriptTime) { + const startTime = card.querySelector("[name='start_time']")?.value || ""; + const endTime = card.querySelector("[name='end_time']")?.value || ""; + clipTranscriptTime.textContent = `${startTime} - ${endTime}`; + } + if (clipTranscriptBody) { + clipTranscriptBody.replaceChildren(); + const loading = document.createElement("p"); + loading.className = "empty-note"; + loading.textContent = "正在读取这一段转写..."; + clipTranscriptBody.append(loading); + } + try { + const startTime = card.querySelector("[name='start_time']")?.value || ""; + const endTime = card.querySelector("[name='end_time']")?.value || ""; + const params = new URLSearchParams({ start_time: startTime, end_time: endTime }); + const response = await fetch(`/api/tasks/${taskId}/clips/${clipId}/transcript-excerpt?${params.toString()}`, {signal: request.signal}); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "读取转写失败"); + } + if (excerptRequest !== request || request.signal.aborted) return; + if (clipTranscriptTitle) clipTranscriptTitle.textContent = data.title || "片段转写"; + if (clipTranscriptTime) clipTranscriptTime.textContent = `${data.start_time} - ${data.end_time}`; + renderTranscriptRows(data.rows || []); + } catch (error) { + if (excerptRequest !== request || request.signal.aborted) return; + if (clipTranscriptBody) { + clipTranscriptBody.replaceChildren(); + const failed = document.createElement("p"); + failed.className = "empty-note"; + failed.textContent = `读取失败:${error.message}`; + clipTranscriptBody.append(failed); + } + } +} + +document.querySelectorAll("[data-preview-trigger]").forEach((button) => { + button.addEventListener("click", () => { + playClipPreview(button.closest("[data-clip-card]")); + }); +}); + +document.querySelectorAll("[data-transcript-trigger]").forEach((button) => { + button.addEventListener("click", () => { + openTranscriptDrawer(button.closest("[data-clip-card]")); + }); +}); + +document.querySelectorAll("[data-source-monitor-trigger]").forEach((button) => { + button.addEventListener("click", () => { + openSourceMonitor(button.closest("[data-clip-card]")); + }); +}); + +document.querySelectorAll("[data-delete-trigger]").forEach((button) => { + button.addEventListener("click", () => { + deleteClipCard(button.closest("[data-clip-card]"), button); + }); +}); + +document.querySelectorAll("[data-reject-reason]").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest("[data-clip-card]"); + if (!card) return; + const selected = card.dataset.feedbackReason === button.dataset.rejectReason; + card.dataset.feedbackReason = selected ? "" : button.dataset.rejectReason; + card.querySelectorAll("[data-reject-reason]").forEach((item) => { + const active = !selected && item === button; + item.classList.toggle("is-active", active); + item.setAttribute("aria-pressed", active ? "true" : "false"); + }); + showClipReviewMessage("淘汰原因已暂存;点击“保存修改”后统一写入。", "info"); + updateClipReviewActionState(); + }); +}); + +if (closeTranscriptDrawerButton) { + closeTranscriptDrawerButton.addEventListener("click", closeTranscriptDrawer); +} + +if (clipPreviewVideo) { + clipPreviewVideo.addEventListener("timeupdate", () => { + if (activePreviewEndSeconds !== null && clipPreviewVideo.currentTime >= activePreviewEndSeconds) { + clipPreviewVideo.pause(); + activePreviewEndSeconds = null; + } + }); +} + +if (closeSourceMonitorButton) { + closeSourceMonitorButton.addEventListener("click", () => toggleSourceMonitor(false)); +} + +if (cancelSourceMonitorButton) { + cancelSourceMonitorButton.addEventListener("click", () => toggleSourceMonitor(false)); +} + +if (applySourceMonitorButton) { + applySourceMonitorButton.addEventListener("click", applySourceMonitorToCard); +} + +if (sourceMonitorModal) { + sourceMonitorModal.addEventListener("click", (event) => { + if (event.target === sourceMonitorModal) toggleSourceMonitor(false); + }); +} + +if (sourceMonitorZoom) { + sourceMonitorZoom.addEventListener("change", renderSourceMonitor); +} + +document.querySelectorAll("[data-source-step]").forEach((button) => { + button.addEventListener("click", () => { + if (!activeSourceMonitor) return; + document.querySelectorAll("[data-source-step]").forEach((item) => item.classList.remove("active")); + button.classList.add("active"); + activeSourceMonitor.stepSeconds = Number(button.dataset.sourceStep || 1); + renderSourceMonitor(); + }); +}); + +document.querySelectorAll("[data-source-action]").forEach((button) => { + button.addEventListener("click", () => { + if (!activeSourceMonitor || !sourceMonitorVideo) return; + const action = button.dataset.sourceAction; + const current = Number(sourceMonitorVideo.currentTime || activeSourceMonitor.startSeconds); + const step = Number(activeSourceMonitor.stepSeconds || 1); + if (action === "mark-in") updateSourceMonitorRange(current, activeSourceMonitor.endSeconds, "start", "in"); + if (action === "mark-out") updateSourceMonitorRange(activeSourceMonitor.startSeconds, current, "end", "out"); + if (action === "jump-in") setSourceMonitorTime(activeSourceMonitor.startSeconds); + if (action === "jump-out") setSourceMonitorTime(activeSourceMonitor.endSeconds); + if (action === "preview") { + activeSourceMonitor.isPreviewing = true; + setSourceMonitorTime(activeSourceMonitor.startSeconds); + sourceMonitorVideo.play().catch(() => {}); + } + if (action === "in-back") updateSourceMonitorRange(activeSourceMonitor.startSeconds - step, activeSourceMonitor.endSeconds, "start", "in"); + if (action === "in-forward") updateSourceMonitorRange(activeSourceMonitor.startSeconds + step, activeSourceMonitor.endSeconds, "start", "in"); + if (action === "out-back") updateSourceMonitorRange(activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds - step, "end", "out"); + if (action === "out-forward") updateSourceMonitorRange(activeSourceMonitor.startSeconds, activeSourceMonitor.endSeconds + step, "end", "out"); + }); +}); + +if (sourceMonitorVideo) { + sourceMonitorVideo.addEventListener("loadedmetadata", renderSourceMonitor); + sourceMonitorVideo.addEventListener("timeupdate", () => { + if (!activeSourceMonitor) return; + if (activeSourceMonitor.isPreviewing && sourceMonitorVideo.currentTime >= activeSourceMonitor.endSeconds) { + sourceMonitorVideo.pause(); + activeSourceMonitor.isPreviewing = false; + setSourceMonitorTime(activeSourceMonitor.endSeconds); + setSourceMonitorMessage("当前片段预览已到出点。", "success"); + return; + } + renderSourceMonitor(); + }); +} + +window.addEventListener("resize", () => { + if (activeSourceMonitor) renderSourceMonitor(); +}); + +if (generateClipsButton) { + generateClipsButton.addEventListener("click", async () => { + if (isCutJobActive || isClipSaveActive || isReviewHandoffActive) return; + const originalText = generateClipsButton.textContent; + isCutJobActive = true; + updateClipReviewActionState(); + generateClipsButton.textContent = "正在确认..."; + showClipReviewMessage("正在保存当前审核选择...", "info"); + + try { + await persistClipReviewChanges(); + generateClipsButton.textContent = "切片处理中..."; + showClipReviewMessage("审核选择已保存,正在创建切片后台任务...", "info"); + const response = await fetch(generateClipsButton.dataset.endpoint, { method: "POST" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "生成切片请求失败"); + } + renderCutJobProgress(data.job, data.message); + showClipReviewMessage(data.message || "切片任务已加入后台队列。", "info"); + await showCompletedCut(data.job_id); + } catch (error) { + showClipReviewMessage(`生成切片失败:${error.message}`, "error"); + } finally { + isCutJobActive = false; + updateClipReviewActionState(); + generateClipsButton.textContent = originalText; + } + }); + restoreCutProgress(); +} diff --git a/app/static/js/new-task.js b/app/static/js/new-task.js new file mode 100644 index 0000000..786f7e0 --- /dev/null +++ b/app/static/js/new-task.js @@ -0,0 +1,104 @@ +// Shared helpers are initialized by base.html, independently of this bundle. + +const newTaskForm = document.querySelector("#new-task-form"); +const newTaskAutoMode = newTaskForm?.querySelector("input[name='auto_mode']"); +const newTaskSubmitButton = document.querySelector("#new-task-submit-button"); +const selectionProfileInput = document.querySelector("#selection-profile"); +const longLiveSettings = document.querySelector("#long-live-settings"); + +function updateNewTaskSubmitLabel() { + if (!newTaskSubmitButton) return; + newTaskSubmitButton.textContent = newTaskAutoMode?.checked ? "创建并自动处理" : "创建任务"; +} + +if (newTaskAutoMode) { + newTaskAutoMode.addEventListener("change", updateNewTaskSubmitLabel); + updateNewTaskSubmitLabel(); +} + +if (selectionProfileInput && longLiveSettings) { + const profiles = JSON.parse(document.querySelector("#content-profile-options")?.textContent || "[]"); + const updateLongLiveSettings = () => { + longLiveSettings.hidden = selectionProfileInput.value !== "long_live_talk"; + const profile = profiles.find(item => item.id === selectionProfileInput.value); + if (!profile) return; + const promptInput = document.querySelector("#new-task-prompt"); + if (promptInput) promptInput.value = newTaskForm.dataset.trialPreset || profile.prompt_preset_id; + const durationInput = newTaskForm.querySelector('[name="max_clip_duration"]'); + const poolInput = newTaskForm.querySelector('[name="candidate_clip_count"]'); + const targetInput = newTaskForm.querySelector('[name="final_clip_target"]'); + const isContent = profile.analyzer_key === "content"; + durationInput.max = isContent ? String(Math.floor(profile.duration.max_seconds / 60)) : "60"; + durationInput.value = isContent ? durationInput.max : "10"; + for (const option of poolInput.options) option.disabled = Number(option.value) > profile.selection.candidate_pool_max; + poolInput.value = String(profile.selection.candidate_pool_default); + if (!poolInput.value) poolInput.value = "12"; + targetInput.value = String(Math.min(12, profile.selection.final_target_default)); + const trialNotice = ["interview_story", "knowledge_opinion"].includes(profile.id) + ? "试用模板:真实内容质量待日常使用验证,请审核后再使用成片。" : ""; + document.querySelector("#selection-profile-hint").textContent = `${trialNotice}${profile.description} 推荐场景:${profile.recommended_scenes.join("、")}。`; + const lo = profile.duration.recommended_min_seconds; + const hi = profile.duration.recommended_max_seconds; + document.querySelector("#profile-duration-hint").textContent = lo + ? `推荐 ${lo}–${hi} 秒;模板硬边界 ${profile.duration.min_seconds}–${profile.duration.max_seconds} 秒。` + : "通用模式按任务时长上限选片,保留完整表达。"; + }; + selectionProfileInput.addEventListener("change", updateLongLiveSettings); + updateLongLiveSettings(); +} + +if (newTaskForm) { + newTaskForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const result = document.querySelector("#new-task-result"); + const submitButton = newTaskForm.querySelector("button[type='submit']"); + const formData = new FormData(newTaskForm); + const payload = Object.fromEntries(formData.entries()); + const videoFileInput = document.querySelector("#video-file-input"); + + submitButton.disabled = true; + result.textContent = "正在创建任务..."; + + try { + if (!payload.selection_profile) throw new Error("请选择选片模式"); + if (!videoFileInput.files.length) throw new Error("请选择要上传的视频文件"); + const uploadData = new FormData(); + for (const key of [ + "task_name", "platform", "max_clip_duration", "candidate_clip_count", + "selection_profile", "final_clip_target", "ai_prompt_preset_id", "ai_provider", + "subtitle_strategy", + ]) uploadData.append(key, payload[key] || ""); + if (payload.challenger_id) { + uploadData.append("challenger_id", payload.challenger_id); + uploadData.append("challenger_sha256", payload.challenger_sha256 || ""); + uploadData.append("confirm_challenger", payload.confirm_challenger === "true" ? "true" : "false"); + } + if (payload.selection_profile === "long_live_talk") { + uploadData.append("highlight_density_per_hour", payload.highlight_density_per_hour || "4"); + uploadData.append("highlight_total_limit", payload.highlight_total_limit || "30"); + } + uploadData.append("ai_preference", ""); + uploadData.append("auto_mode", payload.auto_mode === "true" ? "true" : "false"); + uploadData.append("visual_enabled", payload.visual_enabled === "true" ? "true" : "false"); + uploadData.append("auto_metadata_use_ai", "false"); + uploadData.append("video_file", videoFileInput.files[0]); + const data = await apiFetch("/api/tasks/upload", { method: "POST", body: uploadData }); + result.textContent = `${data.message}${payload.auto_mode === "true" ? " 全自动流水线已启动。" : ""} 正在进入详情页...`; + window.location.href = data.detail_url; + } catch (error) { + result.textContent = `任务创建失败:${error.message}`; + } finally { + submitButton.disabled = false; + } + }); +} + +const videoFileInput = document.querySelector("#video-file-input"); +const videoFileName = document.querySelector("#video-file-name"); + +if (videoFileInput && videoFileName) { + videoFileInput.addEventListener("change", () => { + const file = videoFileInput.files[0]; + videoFileName.textContent = file ? `已选择:${file.name}` : "尚未选择视频文件。"; + }); +} diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js index d6e1239..4055a7a 100644 --- a/app/static/js/publish-center.js +++ b/app/static/js/publish-center.js @@ -6,8 +6,15 @@ if (publishCenterRoot) { const JOB_REFRESH_INTERVAL_MS = 15000; const SERVICE_REFRESH_INTERVAL_MS = 30000; const POLL_REQUEST_TIMEOUT_MS = 10000; - const selectedJobIds = new Set(); + const selections = { content: new Set(), schedule: new Set() }; + let selectedJobIds = selections.content; + let activeTab = 'content'; + let showScheduledContent = false; + let workspace = null; + let drawerTrigger = null; let contentTaskId = document.querySelector("[data-publish-focus]")?.dataset.taskId || ""; + let scheduleFocusTaskId = contentTaskId; + let historyFocusJobId = document.querySelector('[data-publish-focus]')?.dataset.jobId || new URL(window.location.href).searchParams.get('job_id') || ''; const messageNode = document.querySelector("#send-center-message"); const selectionBar = document.querySelector("[data-selection-bar]"); const selectedCountNode = document.querySelector("[data-selected-count]"); @@ -101,7 +108,7 @@ if (publishCenterRoot) { function missingCoverRows() { return Array.from(document.querySelectorAll('[data-publish-row][data-section="content"]')).filter((row) => { const editor = row.querySelector("[data-publish-editor]"); - const coverPath = String(editor?.elements?.cover_file_path?.value || "").trim(); + const coverPath = String(row.dataset.savedCoverPath ?? editor?.elements?.cover_file_path?.value ?? "").trim(); return ( row.dataset.platform === activePlatform && row.dataset.outputActive !== "false" @@ -300,15 +307,44 @@ if (publishCenterRoot) { } function focusScheduleRow(jobId) { + workspace?.setScheduleView('list'); const row = document.querySelector( `[data-publish-row][data-section="schedule"][data-job-id="${CSS.escape(jobId || "")}"]`, ); if (!row || row.hidden) return; - row.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); + row.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center", inline: "nearest" }); row.classList.add("is-calendar-focus"); window.setTimeout(() => row.classList.remove("is-calendar-focus"), 1600); } + function updateScheduleTaskFocus({ scroll = false } = {}) { + const banner = document.querySelector('[data-schedule-task-focus]'); + if (!banner) return; + banner.hidden = !scheduleFocusTaskId; + const rows = scheduleRows(); + const matching = rows.filter(row => !row.hidden && row.dataset.taskId === scheduleFocusTaskId); + for (const row of rows) { + const focused = Boolean(scheduleFocusTaskId && !row.hidden && row.dataset.taskId === scheduleFocusTaskId); + row.classList.toggle('is-task-focus', focused); + const marker = row.querySelector('[data-schedule-task-marker]'); + if (marker) marker.hidden = !focused; + } + if (!scheduleFocusTaskId) return; + const group = document.querySelector(`[data-publish-task-group][data-task-id="${CSS.escape(scheduleFocusTaskId)}"]`); + const name = group?.querySelector('h3')?.textContent?.trim() || scheduleFocusTaskId; + const scheduled = matching.filter(row => row.dataset.status === 'SCHEDULED').length; + const waiting = matching.filter(row => row.dataset.status === 'WAITING').length; + banner.querySelector('[data-schedule-task-message]').textContent = matching.length + ? `已定位任务「${name}」:${scheduled} 条已排期,${waiting} 条待排期。清单仍显示全部任务,蓝色标记属于本任务。` + : `任务「${name}」当前没有待排期或已排期记录。`; + if (scroll && matching.length) { + workspace?.setScheduleView('list'); + const first = matching.find(row => row.dataset.status === 'SCHEDULED') || matching[0]; + first.tabIndex = -1; first.focus({ preventScroll: true }); + first.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: 'center', inline: 'nearest' }); + } + } + function readTaskGroupExpansionState() { try { const raw = window.sessionStorage.getItem(TASK_GROUP_EXPANSION_STORAGE_KEY); @@ -456,6 +492,8 @@ if (publishCenterRoot) { } function renderPlatformSchedule() { + const scheduledContentCount = document.querySelector('[data-scheduled-content-count]'); + if (scheduledContentCount) scheduledContentCount.textContent = `(${document.querySelectorAll('[data-section="content"][data-status="SCHEDULED"]').length})`; const rows = scheduleRows(); ["douyin"].forEach((platform) => { const available = rows.filter((row) => ( @@ -498,6 +536,7 @@ if (publishCenterRoot) { row.dataset.outputActive === "false" || !sectionAllows("content", row.dataset.status || "") || row.dataset.platform !== activePlatform + || (!showScheduledContent && row.dataset.status === 'SCHEDULED') || (contentTaskId && row.closest("[data-publish-task-group]")?.dataset.taskId !== contentTaskId) ); }); @@ -520,6 +559,7 @@ if (publishCenterRoot) { } updateBackfillCoversButton(); applyHistoryFilter(); + updateScheduleTaskFocus(); } function renderCalendar() { @@ -806,7 +846,7 @@ if (publishCenterRoot) { ? "已删除记录" : (historySelectedDate ? `${historySelectedDate} 执行记录` : "全部执行记录"); } - if (historyListEyebrow) historyListEyebrow.textContent = historyDeletedView ? "Deleted Records" : "History"; + if (historyListEyebrow) historyListEyebrow.textContent = historyDeletedView ? "已删除记录" : "执行记录"; if (historyListSummary) { historyListSummary.textContent = historyDeletedView ? `共 ${total} 条 · 删除仅影响页面展示` @@ -910,6 +950,7 @@ if (publishCenterRoot) { page_size: "50", }); if (historySelectedDate && !historyDeletedView) params.set("date", historySelectedDate); + if (historyFocusJobId) params.set('job_id', historyFocusJobId); requests.push( pollingApiFetch(`/api/publish/history/records?${params.toString()}`, controller).then((data) => { if (sequence === historyRequestSequence && !controller.signal.aborted && !document.hidden) { @@ -962,7 +1003,7 @@ if (publishCenterRoot) { const confirmation = hidden ? `确认安全删除 ${ids.length} 条执行记录?\n\n记录会从正常列表和日历中隐藏,但视频、执行明细、数据库历史和平台作品都不会删除。` : `确认恢复 ${ids.length} 条已删除记录?`; - if (!window.confirm(confirmation)) return; + if (!await workspace.confirm(confirmation)) return; try { const endpoint = hidden ? "hide" : "restore"; const data = await window.apiFetch(`/api/publish/history/records/${endpoint}`, { @@ -981,6 +1022,7 @@ if (publishCenterRoot) { sortScheduleRows(); renderPlatformSchedule(); renderCalendar(); + workspace?.dateGroups(); } function queueScheduleRefresh() { @@ -1238,7 +1280,15 @@ if (publishCenterRoot) { const count = selectedJobIds.size; if (selectedCountNode) selectedCountNode.textContent = String(count); if (drawerCount) drawerCount.textContent = String(count); - if (selectionBar) selectionBar.hidden = count === 0; + if (selectionBar) selectionBar.hidden = count === 0 || activeTab === 'history'; + const scope = document.querySelector('[data-selection-scope]'); + if (scope) { + const names = Array.from(selectedJobIds).map(id => document.querySelector(`[data-section="${activeTab === 'history' ? 'schedule' : activeTab}"][data-job-id="${CSS.escape(id)}"] [data-row-title]`)?.textContent?.trim()).filter(Boolean); + scope.textContent = `${activeTab === 'content' ? '内容准备' : '排期计划'} · ${names.slice(0, 3).join('、')}${names.length > 3 ? ` 等 ${names.length} 条` : ''}`; + } + const manageable = count > 0 && Array.from(selectedJobIds).every(id => document.querySelector(`[data-section="schedule"][data-job-id="${CSS.escape(id)}"]`)?.dataset.status === 'SCHEDULED'); + document.querySelector('[data-fixed-selected]').disabled = !manageable; + document.querySelector('[data-managed-selected]').disabled = !manageable; document.querySelectorAll("[data-publish-task-group]").forEach(syncTaskGroupSelectionUi); } @@ -1263,8 +1313,12 @@ if (publishCenterRoot) { } function switchTab(tab) { + if (!['content', 'schedule', 'history'].includes(tab)) tab = 'content'; + activeTab = tab; + selectedJobIds = selections[tab] || new Set(); document.querySelectorAll("[data-center-tab]").forEach((button) => { button.classList.toggle("active", button.dataset.centerTab === tab); + button.setAttribute('aria-selected', String(button.dataset.centerTab === tab)); }); document.querySelectorAll("[data-center-panel]").forEach((panel) => { const active = panel.dataset.centerPanel === tab; @@ -1274,6 +1328,12 @@ if (publishCenterRoot) { const batchAiButton = document.querySelector("[data-batch-ai]"); if (batchAiButton) batchAiButton.hidden = tab !== "content"; if (tab === "history") void refreshHistory({ calendar: true, records: true }); + updateSelectionUi(); + document.querySelector('[data-apply-batch-target]').hidden = tab !== 'content'; + document.querySelector('[data-batch-account]').hidden = tab !== 'content'; + document.querySelector('[data-fixed-selected]').hidden = tab !== 'schedule'; + document.querySelector('[data-managed-selected]').hidden = tab !== 'schedule'; + const url = new URL(window.location.href); url.searchParams.set('tab', tab); window.history.replaceState(null, '', url); } function updateRowFromJob(job, { syncTaskGroups = true } = {}) { @@ -1314,7 +1374,9 @@ if (publishCenterRoot) { const accountNode = row.querySelector("[data-row-account]"); if (accountNode && job.account_name !== undefined) accountNode.textContent = job.account_name || "未选择"; const editor = row.querySelector("[data-publish-editor]"); - if (editor) { + const protectedDraft = editor && workspace?.isLocked(row); + workspace?.observe(row, job); + if (editor && !protectedDraft) { if (job.platform && editor.elements.platform) editor.elements.platform.value = job.platform; const resolvedAccountId = job.effective_account_id || job.account_id || job.send_readiness?.resolved_account_id || ""; if ((job.account_id !== undefined || job.effective_account_id !== undefined) && editor.elements.account_id) { @@ -1333,7 +1395,7 @@ if (publishCenterRoot) { syncPlatformFields(editor); } const coverPreview = row.querySelector("[data-cover-preview]"); - if (coverPreview && job.cover_media_url) { + if (coverPreview && job.cover_media_url && !protectedDraft) { coverPreview.src = job.cover_media_url; coverPreview.hidden = false; } @@ -1367,7 +1429,8 @@ if (publishCenterRoot) { applyRowReadiness(row); }); if (["PUBLISHED", "EXPORTED", "CANCELLED", "PUBLISHING", "NEED_REVIEW", "FAILED"].includes(String(job.status || ""))) { - selectedJobIds.delete(job.id); + selections.content.delete(job.id); + selections.schedule.delete(job.id); updateSelectionUi(); } applyHistoryFilter(); @@ -1379,6 +1442,8 @@ if (publishCenterRoot) { function cloneRowsForRetry(sourceId, job) { document.querySelectorAll(`[data-publish-row][data-job-id="${CSS.escape(sourceId)}"]`).forEach((source) => { const clone = source.cloneNode(true); + clone.classList.remove('is-editing'); + clone.dataset.dirty = 'false'; clone.dataset.jobId = job.id; clone.querySelectorAll("[data-publish-select]").forEach((checkbox) => { checkbox.value = job.id; checkbox.checked = false; }); source.parentElement.appendChild(clone); @@ -1433,12 +1498,16 @@ if (publishCenterRoot) { showScheduleFeedback(); } - function openDrawer() { + async function openDrawer() { if (!selectedJobIds.size) { showMessage("请先选择至少一条任务。", "error"); return; } configureAdaptiveForm(); + const ids = Array.from(selectedJobIds); + if (!await workspace.saveIds(ids)) return; + workspace.closeEditor(); + if (activeTab === 'content') { selections.schedule = new Set(ids); switchTab('schedule'); } const selectedRows = scheduleRows().filter((row) => selectedJobIds.has(row.dataset.jobId)); const context = document.querySelector("[data-reschedule-context]"); if (context) { @@ -1454,12 +1523,15 @@ if (publishCenterRoot) { document.body.classList.add("has-schedule-drawer"); showLatestScheduleNote(); updateSelectionUi(); + drawerTrigger = document.activeElement; + drawer.querySelector('input,select,button')?.focus(); } function closeDrawer() { drawer.hidden = true; drawerBackdrop.hidden = true; document.body.classList.remove("has-schedule-drawer"); + drawerTrigger?.focus(); } function filterAccountOptions(select, platform) { @@ -1514,6 +1586,8 @@ if (publishCenterRoot) { function openAccountDrawer(platform = "") { if (!accountDrawer || !accountBackdrop) return; if (platform && platform !== activePlatform) setActivePlatform(platform); + if (!drawer.hidden) closeDrawer(); + drawerTrigger = document.activeElement; accountDrawer.hidden = false; accountBackdrop.hidden = false; document.body.classList.add("has-schedule-drawer"); @@ -1524,6 +1598,7 @@ if (publishCenterRoot) { document.querySelectorAll("[data-account-row]").forEach((row) => { row.hidden = row.dataset.accountPlatform !== activePlatform; }); + accountDrawer.querySelector('button,input,select')?.focus(); } function readinessAccountId(readiness) { @@ -1536,6 +1611,7 @@ if (publishCenterRoot) { const readiness = effectiveReadiness(row); if (readiness.action === "start_worker") { document.querySelector("[data-worker-help]")?.removeAttribute("hidden"); + document.querySelector(".publish-health-details")?.setAttribute("open", ""); document.querySelector("[data-scheduler-health]")?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); await refreshSchedulerHealth(true); return; @@ -1574,6 +1650,7 @@ if (publishCenterRoot) { } switchTab("content"); const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); + workspace.openEditor(editorRow); editorRow?.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); editorRow?.querySelector("[data-account-select]")?.focus(); showMessage("请在内容准备中选择本次使用的同平台账号并保存。", "error"); @@ -1582,7 +1659,8 @@ if (publishCenterRoot) { if (readiness.action === "complete_content") { switchTab("content"); const editorRow = document.querySelector(`[data-publish-row][data-section="content"][data-job-id="${CSS.escape(row.dataset.jobId)}"]`); - if (editorRow && !editorRow.hidden) { + if (editorRow) { + workspace.openEditor(editorRow); editorRow.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "center" }); editorRow.querySelector("input, textarea, select")?.focus(); } @@ -1677,7 +1755,7 @@ if (publishCenterRoot) { ready: Boolean(schedulerHealthy && data.worker_available), message: schedulerFailures ? (data.last_error_message || "调度扫描异常,正在自动重试") - : (data.worker_available ? "调度器与 Windows Worker 均已连接。" : "发送服务仍在随 Docker 项目自动启动;请稍候,或在 Docker Desktop 中停止后重新运行本项目。"), + : (data.worker_available ? "调度器与发送服务均已连接。" : "发送服务尚未连接。请稍候再检测;若持续未连接,请在项目运行面板确认牛马片场和发送服务均在运行。"), }; } @@ -1771,7 +1849,10 @@ if (publishCenterRoot) { } document.querySelectorAll("[data-center-tab]").forEach((button) => { - button.addEventListener("click", () => switchTab(button.dataset.centerTab)); + button.addEventListener("click", async () => { + if (!await workspace.leaveEditor()) return; + switchTab(button.dataset.centerTab); + }); }); document.querySelector("[data-open-missed-schedules]")?.addEventListener("click", () => { switchTab("schedule"); @@ -1944,38 +2025,7 @@ if (publishCenterRoot) { if (form) { event.preventDefault(); const row = form.closest("[data-publish-row]"); - const jobId = row?.dataset.jobId; - const resultNode = form.querySelector("[data-editor-result]"); - const platform = String(row?.dataset.platform || form.elements.platform.value || "douyin"); - const publishMode = String(form.elements.publish_mode.value || "local_browser"); - const target = { platform, account_id: String(form.elements.account_id.value || ""), publish_mode: publishMode }; - const copyError = validateCopyForm(form); - if (copyError) { - if (resultNode) resultNode.textContent = `保存失败:${copyError}`; - showMessage(copyError, "error"); - return; - } - const content = { - title: String(form.elements.title.value || "").trim(), - description: String(form.elements.description.value || "").trim(), - tags: String(form.elements.tags.value || "").trim(), - visibility: String(form.elements.visibility.value || "public"), - cover_file_path: String(form.elements.cover_file_path.value || ""), - cover_time_seconds: Number(form.elements.cover_time_seconds.value || 0), - allow_download: Boolean(form.elements.allow_download.checked), - bilibili_tid: String(form.elements.bilibili_tid?.value || "娱乐"), - bilibili_copyright: String(form.elements.bilibili_copyright?.value || "original"), - bilibili_source: String(form.elements.bilibili_source?.value || ""), - }; - try { - await window.apiFetch(`/api/publish/jobs/${jobId}/target`, { method: "PATCH", body: JSON.stringify(target) }); - const data = await window.apiFetch(`/api/publish/jobs/${jobId}/send-content`, { method: "PATCH", body: JSON.stringify(content) }); - await saveExperimentAssignment(row, form); - updateRowFromJob(data.job); - if (resultNode) resultNode.textContent = "已保存"; - } catch (error) { - if (resultNode) resultNode.textContent = `保存失败:${error.message}`; - } + await workspace.save(row, { force: true }); return; } @@ -2065,7 +2115,7 @@ if (publishCenterRoot) { "如果已经排期,排期会同时取消。原视频、裁剪成片、字幕和另一个平台的内容都不会删除。", "以后可以在“执行记录”中重新加入。", ].join("\n"); - if (!jobId || !window.confirm(confirmation)) return; + if (!jobId || !await workspace.confirm(confirmation)) return; dismissButton.disabled = true; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/dismiss`, { method: "POST" }); @@ -2089,7 +2139,7 @@ if (publishCenterRoot) { accountSelect.focus(); return; } - if (!sourceId || !window.confirm(sendConfirmation(row, "确认转换并发送?原需复核记录会保留,系统只会创建一条同平台的 Windows Chrome 投稿任务。", visibility))) return; + if (!sourceId || !await workspace.confirm(sendConfirmation(row, "确认转换并发送?原需复核记录会保留,系统只会创建一条同平台的 Windows Chrome 投稿任务。", visibility))) return; repairButton.disabled = true; try { const queryParams = new URLSearchParams({ visibility }); @@ -2109,7 +2159,8 @@ if (publishCenterRoot) { if (publishNowButton) { const publishRow = publishNowButton.closest("[data-publish-row]"); const jobId = publishRow?.dataset.jobId; - if (!jobId || !window.confirm(sendConfirmation(publishRow, "确认立即发送?任务会先进入 SCHEDULED,再由统一调度器执行真实投稿。"))) return; + if (jobId && !await workspace.saveIds([jobId])) { switchTab('content'); return; } + if (!jobId || !await workspace.confirm(sendConfirmation(publishRow, "确认立即发送?任务会先进入 SCHEDULED,再由统一调度器执行真实投稿。"))) return; publishNowButton.disabled = true; try { const result = await window.apiFetch(`/api/publish/jobs/${jobId}/publish-now`, { method: "POST" }); @@ -2148,7 +2199,7 @@ if (publishCenterRoot) { "", "当前排期会清除;视频、标题、简介、话题和封面都会保留。", ].join("\n"); - if (!jobId || !window.confirm(confirmation)) return; + if (!jobId || !await workspace.confirm(confirmation)) return; cancelButton.disabled = true; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/cancel`, { method: "POST" }); @@ -2175,7 +2226,7 @@ if (publishCenterRoot) { if (restoreButton) { const row = restoreButton.closest("[data-publish-row]"); const jobId = row?.dataset.jobId; - if (!jobId || !window.confirm(`确认把这条${platformLabel(row?.dataset.platform)}内容重新加入“内容准备”?\n\n恢复后不会自动排期或发送。`)) return; + if (!jobId || !await workspace.confirm(`确认把这条${platformLabel(row?.dataset.platform)}内容重新加入“内容准备”?\n\n恢复后不会自动排期或发送。`)) return; restoreButton.disabled = true; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/restore`, { method: "POST" }); @@ -2191,14 +2242,8 @@ if (publishCenterRoot) { const metadataButton = event.target.closest("[data-generate-metadata]"); if (metadataButton) { const row = metadataButton.closest("[data-publish-row]"); - const jobId = row?.dataset.jobId; metadataButton.disabled = true; - try { - const data = await window.apiFetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" }); - applyGeneratedMetadataToForm(row, data.job); - updateRowFromJob(data.job); - showMessage("本条 AI 文案已重写并保存到最终发送字段。", "success"); - } catch (error) { showMessage(`本条 AI 重写失败:${error.message}`, "error"); } + try { await workspace.preview([row.dataset.jobId]); } finally { metadataButton.disabled = false; } return; } @@ -2208,7 +2253,8 @@ if (publishCenterRoot) { const row = coverButton.closest("[data-publish-row]"); const form = row?.querySelector("[data-publish-editor]"); const current = Number(form?.elements.cover_time_seconds?.value || 0); - const rawSeconds = window.prompt("请输入要作为封面的画面秒数(例如 3.5):", String(current)); + if (!await workspace.save(row)) return; + const rawSeconds = await workspace.pickCover(row, current); if (rawSeconds === null) return; const seconds = Number(rawSeconds); if (!Number.isFinite(seconds) || seconds < 0) { showMessage("封面秒数必须是大于或等于 0 的数字。", "error"); return; } @@ -2222,13 +2268,12 @@ if (publishCenterRoot) { video_source: coverButton.dataset.videoSource || "original", title: String(form.elements.title.value || "发布封面"), cover_time_seconds: seconds, + expected_updated_at: workspace.version(row), }), }); - form.elements.cover_file_path.value = data.cover_file_path || ""; - form.elements.cover_time_seconds.value = String(seconds); + workspace.acceptCover(row, data.job); const preview = row.querySelector("[data-cover-preview]"); if (preview && data.cover_media_url) { preview.src = data.cover_media_url; preview.hidden = false; } - updateRowFromJob(data.job); showMessage(data.message || "封面已生成。", "success"); } catch (error) { showMessage(`封面生成失败:${error.message}`, "error"); } finally { coverButton.disabled = false; } @@ -2243,10 +2288,11 @@ if (publishCenterRoot) { showMessage("当前平台与任务不一致,已阻止加入计划。", "error"); return; } - selectedJobIds.add(jobId); - updateSelectionUi(); + if (!await workspace.save(planRow)) { workspace.openEditor(planRow); return; } + selections.schedule = new Set([jobId]); switchTab("schedule"); - openDrawer(); + updateSelectionUi(); + await openDrawer(); return; } @@ -2255,7 +2301,7 @@ if (publishCenterRoot) { const sourceId = retryButton.closest("[data-publish-row]")?.dataset.jobId; const retryRow = retryButton.closest("[data-publish-row]"); const visibility = retryRow?.querySelector("[data-retry-visibility]")?.value || retryRow?.dataset.visibility || "public"; - if (!window.confirm(`${sendConfirmation(retryRow, "确认立即发送?", visibility)}\n\n系统会保留原失败记录,并创建一条立即执行的新任务。`)) return; + if (!await workspace.confirm(`${sendConfirmation(retryRow, "确认立即发送?", visibility)}\n\n系统会保留原失败记录,并创建一条立即执行的新任务。`)) return; retryButton.disabled = true; try { const data = await window.apiFetch(`/api/publish/jobs/${sourceId}/retry`, { method: "POST", body: JSON.stringify({ visibility }) }); @@ -2274,8 +2320,8 @@ if (publishCenterRoot) { if (markPublished) { const row = markPublished.closest("[data-publish-row]"); const jobId = row?.dataset.jobId; - const platformUrl = window.prompt("请粘贴已经人工核对过的平台作品链接。没有链接不能标记成功:", ""); - if (!platformUrl || !window.confirm("确认该链接对应本任务,并将任务标记为已发布?")) return; + const platformUrl = await workspace.confirm("请粘贴已经人工核对过的平台作品链接。没有链接不能标记成功:", true); + if (!platformUrl || !await workspace.confirm("确认该链接对应本任务,并将任务标记为已发布?")) return; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/mark-published`, { method: "POST", body: JSON.stringify({ platform_url: platformUrl }) }); updateRowFromJob(data.job || { id: jobId, status: "PUBLISHED", platform_url: platformUrl }); @@ -2287,7 +2333,7 @@ if (publishCenterRoot) { const markFailed = event.target.closest("[data-mark-failed]"); if (markFailed) { const jobId = markFailed.closest("[data-publish-row]")?.dataset.jobId; - if (!window.confirm("请先在平台确认没有发布成功。确认将本任务标记为失败?")) return; + if (!await workspace.confirm("请先在平台确认没有发布成功。确认将本任务标记为失败?")) return; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/mark-failed`, { method: "POST" }); updateRowFromJob(data.job || { id: jobId, status: "FAILED" }); @@ -2312,8 +2358,7 @@ if (publishCenterRoot) { const jobId = viewEvents.closest("[data-publish-row]")?.dataset.jobId; try { const data = await window.apiFetch(`/api/publish/jobs/${jobId}/events`); - const lines = (data.events || []).map((item) => `${item.occurred_at} · ${item.from_status || "—"} → ${item.to_status || "—"} · ${item.message || item.event_type}`); - window.alert(lines.join("\n") || "暂无执行事件。"); + workspace.events(data.events || []); } catch (error) { showMessage(`读取执行详情失败:${error.message}`, "error"); } return; } @@ -2348,9 +2393,21 @@ if (publishCenterRoot) { document.querySelector("[data-clear-selection]")?.addEventListener("click", () => { selectedJobIds.clear(); updateSelectionUi(); }); document.querySelector("[data-open-account-drawer]")?.addEventListener("click", () => openAccountDrawer()); - function closeAccountDrawer() { accountDrawer.hidden = true; accountBackdrop.hidden = true; document.body.classList.remove("has-schedule-drawer"); } + function closeAccountDrawer() { accountDrawer.hidden = true; accountBackdrop.hidden = true; document.body.classList.remove("has-schedule-drawer"); drawerTrigger?.focus(); } document.querySelector("[data-close-account-drawer]")?.addEventListener("click", closeAccountDrawer); accountBackdrop?.addEventListener("click", closeAccountDrawer); + document.addEventListener('keydown', event => { + if (document.querySelector('dialog[open]')) return; + const panel = !drawer.hidden ? drawer : (!accountDrawer.hidden ? accountDrawer : null); + if (!panel) return; + if (event.key === 'Escape') { event.preventDefault(); if (panel === drawer) closeDrawer(); else closeAccountDrawer(); } + if (event.key === 'Tab') { + const controls = Array.from(panel.querySelectorAll('button,input,select,textarea,a[href]')).filter(item => !item.disabled && item.getClientRects().length); + const first = controls[0], last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + } + }); document.querySelector("[data-apply-batch-target]")?.addEventListener("click", async () => { const payload = { @@ -2366,26 +2423,11 @@ if (publishCenterRoot) { } catch (error) { showMessage(`批量设置失败:${error.message}`, "error"); } }); - document.querySelector("[data-batch-ai]")?.addEventListener("click", async () => { - let succeeded = 0; - let failed = 0; - for (const jobId of Array.from(selectedJobIds)) { - try { - const data = await window.apiFetch(`/api/publish/jobs/${jobId}/metadata?use_ai=true`, { method: "POST" }); - const row = document.querySelector( - `[data-publish-row][data-section="content"][data-job-id="${CSS.escape(jobId)}"]`, - ); - applyGeneratedMetadataToForm(row, data.job); - updateRowFromJob(data.job); - succeeded += 1; - } catch (_error) { - failed += 1; - } - } - showMessage( - `已选文案 AI 重写完成:成功 ${succeeded} 条,失败 ${failed} 条;失败项已保留原文。`, - failed ? "error" : "success", - ); + document.querySelector("[data-batch-ai]")?.addEventListener("click", async (event) => { + const button = event.currentTarget; + button.disabled = true; + try { await workspace.preview(Array.from(selectedJobIds)); } + finally { button.disabled = false; } }); backfillCoversButton?.addEventListener("click", async () => { @@ -2545,6 +2587,29 @@ if (publishCenterRoot) { syncPlatformFields(form); syncCopyCounters(form); }); + workspace = window.NiuMaPublishWorkspace({ updateJob: updateRowFromJob, validateCopy: validateCopyForm, saveExperiment: saveExperimentAssignment, syncCounters: syncCopyCounters, showMessage, revealRow: row => { + if (row.dataset.status === 'SCHEDULED') { showScheduledContent = true; document.querySelector('[data-show-scheduled]').checked = true; } + const group = row.closest('[data-publish-task-group]'); + if (group) expandedTaskGroupIds.add(group.dataset.taskId); + if (contentTaskId && row.dataset.taskId !== contentTaskId) { contentTaskId = ''; document.querySelector('[data-content-task-focus]')?.setAttribute('hidden', ''); } + renderPlatformSchedule(); + } }); + document.querySelector('[data-history-job-focus]').hidden = !historyFocusJobId; + document.querySelector('[data-clear-history-job-focus]').addEventListener('click', () => { + historyFocusJobId = ''; document.querySelector('[data-history-job-focus]').hidden = true; + const url = new URL(window.location.href); url.searchParams.delete('job_id'); window.history.replaceState(null, '', url); + historyPage = 1; void refreshHistory({ calendar: true, records: true }); + }); + document.querySelector('[data-show-scheduled]').addEventListener('change', event => { showScheduledContent = event.target.checked; renderPlatformSchedule(); }); + document.querySelector('[data-locate-schedule-task]')?.addEventListener('click', () => { scheduleFilter = 'all'; refreshScheduleViews(); updateScheduleTaskFocus({ scroll: true }); }); + document.querySelector('[data-clear-schedule-task-focus]')?.addEventListener('click', () => { + scheduleFocusTaskId = ''; contentTaskId = ''; + document.querySelector('[data-content-task-focus]')?.setAttribute('hidden', ''); + const url = new URL(window.location.href); url.searchParams.delete('task_id'); window.history.replaceState(null, '', url); + renderPlatformSchedule(); + }); + const scheduledCount = document.querySelectorAll('[data-section="content"][data-status="SCHEDULED"]').length; + document.querySelector('[data-scheduled-content-count]').textContent = `(${scheduledCount})`; initializeTaskGroupExpansionState(); document.querySelector("[data-clear-content-task]")?.addEventListener("click", () => { contentTaskId = ""; @@ -2557,11 +2622,23 @@ if (publishCenterRoot) { const focus = document.querySelector("[data-publish-focus]"); if (focus?.dataset.platform) setActivePlatform(focus.dataset.platform); if (focus?.dataset.tab) switchTab(focus.dataset.tab); + const focusJobId = focus?.dataset.jobId || new URL(window.location.href).searchParams.get('job_id') || ''; + if (focusJobId && activeTab === 'content') { + const row = document.querySelector(`[data-section="content"][data-job-id="${CSS.escape(focusJobId)}"]`); + if (row) { + if (row.dataset.status === 'SCHEDULED') { showScheduledContent = true; document.querySelector('[data-show-scheduled]').checked = true; } + const group = row.closest('[data-publish-task-group]'); if (group) expandedTaskGroupIds.add(group.dataset.taskId); + renderPlatformSchedule(); workspace.openEditor(row); + } + } + if (focusJobId && activeTab === 'schedule') window.requestAnimationFrame(() => focusScheduleRow(focusJobId)); filterAccountOptions(document.querySelector("[data-batch-account]"), activePlatform); if (scheduleForm?.elements.start_at_local) scheduleForm.elements.start_at_local.value = beijingDatetimeValue(Date.now() + 10 * 60 * 1000); document.querySelectorAll('[data-publish-row][data-section="schedule"], [data-publish-row][data-section="history"]').forEach((row) => applyRowReadiness(row)); updateSelectionUi(); applyHistoryFilter(); refreshScheduleViews(); updateBackfillCoversButton(); - if (focus?.dataset.taskId) { + if (focus?.dataset.taskId && activeTab === 'schedule') { + window.requestAnimationFrame(() => updateScheduleTaskFocus({ scroll: !focusJobId })); + } else if (focus?.dataset.taskId && activeTab === 'content') { const group = document.querySelector( `[data-publish-task-group][data-task-id="${CSS.escape(focus.dataset.taskId)}"]`, ); @@ -2569,7 +2646,8 @@ if (publishCenterRoot) { setTaskGroupExpanded(group, true, { remember: true }); group.scrollIntoView({ behavior: window.preferredScrollBehavior(), block: "start" }); } else { - showMessage("已定位到该处理任务,但当前没有可准备的抖音新版本内容。可返回任务页重新同步。"); + const scheduled = scheduleRows().filter(row => row.dataset.taskId === focus.dataset.taskId && row.dataset.status === 'SCHEDULED').length; + showMessage(scheduled ? `本任务有 ${scheduled} 条已排期记录,可在“排期计划”中查看;勾选“显示已排期内容”可继续编辑。` : '本任务当前没有待准备内容,可在执行记录查看已有发送结果。'); } } document.addEventListener("visibilitychange", () => { @@ -2620,7 +2698,7 @@ if (publishCenterRoot) { if (!data.available) return; const p = data.policy; document.querySelector('[data-adaptive-summary]').textContent = `${p.enabled ? '动态策略已启用(同步不改期)' : '动态策略未启用'} · 每天最多 ${p.daily_limit} 条 · 最小间隔 ${p.min_gap_minutes} 分钟 · ${data.strategy.captured_at ? '数据更新:'+formatBeijingTimestamp(data.strategy.captured_at) : '尚无数据'} · ${data.strategy.reason}`; - document.querySelector('[data-toggle-adaptive]').textContent = p.enabled ? '停用动态策略(保留当前时间)' : '启用并纳入现有排期'; + document.querySelector('[data-toggle-adaptive]').textContent = p.enabled ? '预览停用策略影响' : '预览启用策略影响'; document.querySelector('[data-adaptive-data-note]').textContent = `${data.strategy.sample_count} 条可比较作品 · ${data.strategy.reason}`; const scores = document.querySelector('[data-adaptive-scores]'); scores.replaceChildren(); (data.strategy.bins || []).forEach(b => { @@ -2643,15 +2721,16 @@ if (publishCenterRoot) { document.querySelector('[data-toggle-adaptive]')?.addEventListener('click', async () => { const p=adaptiveContext?.policy; if (!p) return; try { - await window.apiFetch(`/api/publish/schedules/adaptive/${encodeURIComponent(p.account_id)}`, {method:'PATCH',body:JSON.stringify({enabled:!p.enabled,include_existing:!p.enabled,daily_limit:p.daily_limit,min_gap_minutes:p.min_gap_minutes,daily_start_time:p.daily_start_time,daily_end_time:p.daily_end_time})}); - invalidatePreview(); await refreshAdaptive(); + await workspace.previewPolicy(p, async () => { invalidatePreview(); await refreshAdaptive(); }); } catch(error) { showMessage(error.message,'error'); } }); async function updateManaged(managed) { if (!selectedJobIds.size) return; + const ids = Array.from(selectedJobIds); + if (!await workspace.confirm(`确认${managed ? '将所选重新加入动态管理' : '固定所选时间'} ${ids.length} 条?\n\n${managed ? '后台会按账号策略核对明天以后的受管排期;当天、已执行及待复核记录受保护。' : '当前时间保留,后续动态调整会跳过这些作品。'}`)) return; let done=0; try { - for (const id of selectedJobIds) { await window.apiFetch(`/api/publish/jobs/${encodeURIComponent(id)}/adaptive`,{method:'PATCH',body:JSON.stringify({managed})}); done++; } + for (const id of ids) { await window.apiFetch(`/api/publish/jobs/${encodeURIComponent(id)}/adaptive`,{method:'PATCH',body:JSON.stringify({managed})}); done++; } showMessage(`已${managed ? '加入动态管理' : '固定时间'} ${done} 条任务`); invalidatePreview(); await refreshAdaptive(); } catch(error) { showMessage(`已更新 ${done} 条;${error.message}`,'error'); } } diff --git a/app/static/js/publish-workspace.js b/app/static/js/publish-workspace.js new file mode 100644 index 0000000..660f447 --- /dev/null +++ b/app/static/js/publish-workspace.js @@ -0,0 +1,293 @@ +/* Focused editors, draft protection and review dialogs for the sending center. */ +window.NiuMaPublishWorkspace = function ({ updateJob, validateCopy, saveExperiment, syncCounters, showMessage, revealRow }) { + const drafts = new WeakMap(); + const backdrop = document.querySelector('[data-content-editor-backdrop]'); + let activeRow = null; + let editorTrigger = null; + const node = (tag, text) => { const item = document.createElement(tag); if (text !== undefined) item.textContent = text; return item; }; + const displayTime = value => { const date = new Date(value); return value && !Number.isNaN(date.getTime()) ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date) : (value || '未排期'); }; + const formFor = row => row?.querySelector('[data-publish-editor]'); + const signature = form => JSON.stringify(Array.from(form.elements).filter(item => item.name).map(item => [item.name, item.type === 'checkbox' ? item.checked : item.value])); + function state(row) { + const form = formFor(row); + if (!form) return null; + if (!drafts.has(form)) drafts.set(form, { signature: signature(form), version: row.dataset.updatedAt || '', saving: null }); + return drafts.get(form); + } + function isDirty(row) { const saved = state(row); return Boolean(saved && saved.signature !== signature(formFor(row))); } + function isLocked(row) { return isDirty(row) || Boolean(state(row)?.saving); } + function label(row, text) { + row?.querySelectorAll('[data-editor-save-state], [data-summary-save-state], [data-editor-result]').forEach(item => { item.textContent = text; }); + if (row) row.dataset.dirty = String(isDirty(row)); + } + function observe(row, job) { + const form = formFor(row), saved = state(row); + if (!form || !saved) return; + row.dataset.savedCoverPath = job.cover_file_path || ''; + if (isLocked(row)) { + if (job.updated_at && job.updated_at !== saved.version) { + label(row, '有未保存修改 · 服务器版本已变化,请先核对'); + row.querySelector('[data-reconcile-editor]').hidden = false; + } + return; + } + for (const key of ['title', 'description', 'tags', 'visibility', 'cover_file_path', 'cover_time_seconds']) { + if (job[key] !== undefined && form.elements[key]) form.elements[key].value = job[key] ?? ''; + } + if (job.account_id !== undefined || job.effective_account_id !== undefined) form.elements.account_id.value = job.effective_account_id || job.account_id || job.send_readiness?.resolved_account_id || ''; + if (job.publish_mode !== undefined) form.elements.publish_mode.value = job.send_readiness?.resolved_publish_mode || job.publish_mode; + if (job.allow_download !== undefined) form.elements.allow_download.checked = Boolean(job.allow_download); + saved.version = job.updated_at || saved.version; + row.dataset.updatedAt = saved.version; + saved.signature = signature(form); + syncCounters(form); + label(row, '已保存'); + row.querySelector('[data-reconcile-editor]').hidden = true; + } + function content(row, suggestion = null, expected = null) { + const form = formFor(row), saved = state(row); + return { + title: suggestion?.title ?? form.elements.title.value.trim(), + description: suggestion?.description ?? form.elements.description.value.trim(), + tags: suggestion?.tags ?? form.elements.tags.value.trim(), + account_id: form.elements.account_id.value, publish_mode: form.elements.publish_mode.value, + visibility: form.elements.visibility.value, cover_file_path: form.elements.cover_file_path.value, + cover_time_seconds: Number(form.elements.cover_time_seconds.value || 0), + allow_download: form.elements.allow_download.checked, + expected_updated_at: expected ?? saved.version, + }; + } + function acceptCover(row, job) { + const form = formFor(row), saved = state(row); + // This window committed the cover. Advance only those baseline fields; + // typing during the request remains a local unsaved change. + const values = { cover_file_path: job.cover_file_path || '', cover_time_seconds: String(job.cover_time_seconds || 0) }; + for (const [key, value] of Object.entries(values)) form.elements[key].value = value; + saved.signature = JSON.stringify(JSON.parse(saved.signature).map(([key, value]) => [key, values[key] ?? value])); + saved.version = job.updated_at; row.dataset.updatedAt = saved.version; + row.dataset.savedCoverPath = values.cover_file_path; + updateJob(job); + label(row, isDirty(row) ? '封面已保存 · 还有未保存修改' : '已保存'); + } + async function save(row, { suggestion = null, expected = null, force = false } = {}) { + const form = formFor(row), saved = state(row); + if (!form) return true; + if (saved.saving) return saved.saving; + if (!force && !suggestion && !isDirty(row)) return true; + const validation = suggestion ? '' : validateCopy(form); + if (validation) { label(row, `保存失败:${validation}`); showMessage(validation, 'error'); return false; } + const controls = Array.from(form.querySelectorAll('input,select,textarea,button')); + const disabled = controls.map(item => item.disabled); + controls.forEach(item => { item.disabled = true; }); + label(row, '正在保存…'); + const payload = content(row, suggestion, expected); + saved.saving = (async () => { + let contentCommitted = false; + try { + const data = await window.apiFetch(`/api/publish/jobs/${encodeURIComponent(row.dataset.jobId)}/send-content`, { method: 'PATCH', body: JSON.stringify(payload) }); + contentCommitted = true; + // Content is committed already. Keep that version even if optional assignment fails. + for (const key of ['title', 'description', 'tags']) if (data.job[key] !== undefined) form.elements[key].value = data.job[key]; + saved.version = data.job.updated_at; + row.dataset.updatedAt = saved.version; + await saveExperiment(row, form); + saved.signature = signature(form); + updateJob(data.job); + label(row, '已保存'); + row.querySelector('[data-reconcile-editor]').hidden = true; + return true; + } catch (error) { + label(row, contentCommitted ? `文案与配置已保存,实验归属未保存:${error.message}` : (error.status === 409 ? '版本冲突 · 输入已保留,请核对服务器内容后重试' : `保存失败:${error.message}`)); + if (error.status === 409) row.querySelector('[data-reconcile-editor]').hidden = false; + showMessage(`保存失败:${error.message}`, 'error'); + return false; + } finally { + controls.forEach((item, index) => { item.disabled = disabled[index]; }); + saved.saving = null; + syncCounters(form); + } + })(); + return saved.saving; + } + async function saveIds(ids) { + for (const id of ids) { + const row = document.querySelector(`[data-section="content"][data-job-id="${CSS.escape(id)}"]`); + if (row && !await save(row)) { openEditor(row); return false; } + } + return true; + } + function closeEditor() { + if (!activeRow) return; + activeRow.classList.remove('is-editing'); + activeRow.querySelectorAll('video').forEach(video => video.pause()); + const dialog = activeRow.querySelector('[data-content-editor-dialog]'); + activeRow = null; backdrop.hidden = true; + if (dialog.open) dialog.close(); + document.body.classList.remove('has-publish-editor'); + editorTrigger?.focus(); + } + function openEditor(row) { + if (!row) return; + revealRow?.(row); + if (activeRow !== row) closeEditor(); + activeRow = row; editorTrigger = row.querySelector('[data-open-content-editor]'); + row.classList.add('is-editing'); backdrop.hidden = true; + row.hidden = false; + const group = row.closest('[data-publish-task-group]'); + if (group) { group.hidden = false; group.querySelector('[data-task-group-body]').hidden = false; } + document.body.classList.add('has-publish-editor'); + const form = formFor(row), dialog = row.querySelector('[data-content-editor-dialog]'); + if (!dialog.open) dialog.showModal(); + dialog.onclose = () => { if (activeRow === row) closeEditor(); }; + state(row); form.querySelector('input[name="title"]')?.focus(); + } + async function leaveEditor() { + if (activeRow && !await save(activeRow)) return false; + closeEditor(); return true; + } + function confirm(text, input = false, initial = '') { + const dialog = document.querySelector('[data-publish-confirm]'); + if (dialog.open) return Promise.resolve(input ? null : false); + dialog.querySelector('[data-confirm-copy]').textContent = text; + const field = dialog.querySelector('[data-confirm-input]'); field.value = initial; + dialog.querySelector('[data-confirm-input-wrap]').hidden = !input; + dialog.returnValue = ''; + return new Promise(resolve => { + dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm' ? (input ? field.value.trim() : true) : (input ? null : false)), { once: true }); + dialog.showModal(); + }); + } + async function preview(ids) { + const dialog = document.querySelector('[data-ai-preview-dialog]'), host = dialog.querySelector('[data-ai-preview-list]'); + host.replaceChildren(); dialog.showModal(); + for (const id of ids) { + const row = document.querySelector(`[data-section="content"][data-job-id="${CSS.escape(id)}"]`); + if (!row) continue; + const form = formFor(row), item = node('article'); item.className = 'publish-ai-preview-item'; + const status = node('p', '正在生成本条建议…'); item.append(node('h3', form.elements.title.value), status); host.append(item); + try { + const data = await window.apiFetch(`/api/publish/jobs/${encodeURIComponent(id)}/metadata/preview`, { method: 'POST' }); + const comparison = node('div'); comparison.className = 'publish-ai-comparison'; + for (const [name, copy] of [['当前输入', content(row)], ['AI 建议', data.metadata]]) { + const column = node('div'); column.append(node('strong', name), node('p', `标题:${copy.title}`), node('p', `简介:${copy.description}`), node('p', `话题:${copy.tags}`)); comparison.append(column); + } + const accept = node('button', '接受并保存本条'); accept.type = 'button'; accept.className = 'primary-button'; accept.dataset.acceptAiSuggestion = id; + accept.addEventListener('click', async () => { + if (isDirty(row) && !await confirm('接受建议会替换本条当前输入中的标题、简介和话题,并保存其余配置。确认接受吗?')) return; + accept.disabled = true; + const ok = await save(row, { suggestion: data.metadata, expected: data.expected_updated_at }); + status.textContent = ok ? '本条建议已接受并保存' : '保存未全部完成;请先核对编辑器中的错误提示'; + accept.disabled = ok; + }); + status.textContent = '建议尚未保存'; item.append(comparison, accept); + } catch (error) { status.textContent = `本条生成失败:${error.message};原文保持不变`; } + if (!dialog.open) break; + } + } + function pickCover(row, current) { + const dialog = document.querySelector('[data-cover-dialog]'), video = dialog.querySelector('video'); + const number = dialog.querySelector('[data-cover-time]'), range = dialog.querySelector('[data-cover-range]'); + video.src = row.querySelector('[data-content-video]')?.src || row.querySelector('video')?.src || ''; + number.value = current; range.value = current; dialog.returnValue = ''; + const update = seconds => { number.value = Number(seconds).toFixed(1); range.value = seconds; }; + video.onloadedmetadata = () => { range.max = video.duration || 1; number.max = video.duration || 1; video.currentTime = Math.min(current, video.duration || current); }; + video.preload = 'metadata'; video.load(); + video.ontimeupdate = () => update(video.currentTime); + range.oninput = () => { video.pause(); video.currentTime = Number(range.value); update(range.value); }; + number.onchange = () => { video.pause(); video.currentTime = Math.max(0, Math.min(Number(number.value), Number(range.max))); }; + dialog.querySelector('[data-confirm-cover]').onclick = () => dialog.close('confirm'); + return new Promise(resolve => { + dialog.addEventListener('close', () => { video.pause(); resolve(dialog.returnValue === 'confirm' ? Number(number.value) : null); }, { once: true }); + dialog.showModal(); + }); + } + function events(items) { + const dialog = document.querySelector('[data-events-dialog]'), host = dialog.querySelector('[data-events-list]'); host.replaceChildren(); + const labels = { DRAFT: '草稿', WAITING: '等待安排', SCHEDULED: '已排期', PUBLISHING: '发送中', PUBLISHED: '已发布', EXPORTED: '已导出', FAILED: '失败', NEED_REVIEW: '待人工复核', CANCELLED: '已取消' }; + for (const event of items) { const row = node('article'); row.append(node('time', `${displayTime(event.occurred_at)} · 北京时间`), node('strong', `${labels[event.from_status] || event.from_status || '—'} → ${labels[event.to_status] || event.to_status || '—'}`), node('p', event.message || event.event_type)); host.append(row); } + if (!items.length) host.append(node('p', '暂无执行事件。')); + dialog.showModal(); + } + let policyPreview = null, policyPayload = null, policyRefresh = null; + async function previewPolicy(policy, refresh) { + const payload = { enabled: !policy.enabled, include_existing: !policy.enabled, daily_limit: policy.daily_limit, min_gap_minutes: policy.min_gap_minutes, daily_start_time: policy.daily_start_time, daily_end_time: policy.daily_end_time }; + policyPayload = payload; policyRefresh = refresh; + const data = await window.apiFetch(`/api/publish/schedules/adaptive/${encodeURIComponent(policy.account_id)}/preview`, { method: 'POST', body: JSON.stringify(payload) }); + policyPreview = data; + const dialog = document.querySelector('[data-policy-dialog]'), host = dialog.querySelector('[data-policy-impact]'); + dialog.querySelector('[data-policy-summary]').textContent = `康熙来了 · ${payload.enabled ? '启用动态策略' : '停用动态策略'};未来受管 ${data.managed_count} 条,其中新纳入 ${data.newly_managed_count} 条,${data.protected_count} 条保持保护。${data.message}`; + host.replaceChildren(); + for (const item of data.schedule || []) host.append(node('p', `${item.title}:${displayTime(item.old_time)} → ${item.scheduled_at_local_display || displayTime(item.scheduled_at_utc)}`)); + if (data.protected?.length) { + const details = node('details'); details.append(node('summary', `查看 ${data.protected_count} 条受保护记录`)); + for (const item of data.protected) details.append(node('p', `${item.title}:${item.reason}`)); + host.append(details); + } + if (!data.schedule?.length) host.append(node('p', '本次没有拟调整时间。当天、人工固定、已执行和待复核记录保持保护。')); + dialog.querySelector('[data-policy-confirm]').checked = false; dialog.querySelector('[data-apply-policy]').disabled = true; + dialog.querySelector('[data-policy-feedback]').textContent = ''; dialog.showModal(); + } + document.querySelector('[data-policy-confirm]').addEventListener('change', event => { document.querySelector('[data-apply-policy]').disabled = !event.target.checked; }); + document.querySelector('[data-apply-policy]').addEventListener('click', async event => { + if (!policyPreview || !document.querySelector('[data-policy-confirm]').checked) return; + const button = event.currentTarget; button.disabled = true; + try { + await window.apiFetch(`/api/publish/schedules/adaptive/${encodeURIComponent(policyPreview.account_id)}/apply-preview`, { method: 'POST', body: JSON.stringify({ ...policyPayload, preview_token: policyPreview.preview_token, confirmed: true }) }); + document.querySelector('[data-policy-dialog]').close(); await policyRefresh(); showMessage('策略已按本次确认保存。'); + } catch (error) { document.querySelector('[data-policy-feedback]').textContent = `${error.message};请关闭后重新生成影响预览。`; } + }); + let scheduleView = 'list'; + function setScheduleView(view) { + scheduleView = view; + document.querySelector('[data-schedule-calendar-card]').hidden = view !== 'calendar'; + document.querySelector('.publish-plan-list').hidden = view === 'calendar'; + document.querySelector('.publish-plan-header').hidden = view === 'calendar'; + document.querySelectorAll('[data-schedule-view]').forEach(button => { button.classList.toggle('is-active', button.dataset.scheduleView === view); button.setAttribute('aria-pressed', String(button.dataset.scheduleView === view)); }); + } + function dateGroups() { + const list = document.querySelector('.publish-plan-list'); list.querySelectorAll('[data-date-heading]').forEach(item => item.remove()); + let previous = ''; + for (const row of list.querySelectorAll('[data-section="schedule"]')) { + if (row.hidden) continue; + const time = row.querySelector('[data-row-schedule]'); + const date = time.dataset.utc ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(time.dataset.utc)) : '尚未排期'; + if (date !== previous) { const title = node('h3', date); title.dataset.dateHeading = ''; title.className = 'publish-date-heading'; list.insertBefore(title, row); previous = date; } + } + setScheduleView(scheduleView); + } + document.querySelectorAll('[data-schedule-view]').forEach(button => button.addEventListener('click', () => setScheduleView(button.dataset.scheduleView))); + document.addEventListener('input', event => { const form = event.target.closest('[data-publish-editor]'); if (form) { const row = form.closest('[data-publish-row]'); label(row, isDirty(row) ? '有未保存修改' : '已保存'); } }); + document.addEventListener('change', event => { const form = event.target.closest('[data-publish-editor]'); if (form) { const row = form.closest('[data-publish-row]'); label(row, isDirty(row) ? '有未保存修改' : '已保存'); } }); + document.addEventListener('click', async event => { + const reconcile = event.target.closest('[data-reconcile-editor]'); + if (reconcile) { + const row = reconcile.closest('[data-publish-row]'); reconcile.disabled = true; + try { + const data = await window.apiFetch(`/api/publish/jobs?job_ids=${encodeURIComponent(row.dataset.jobId)}`), job = data.jobs?.[0]; + if (!job) throw new Error('发送记录已不存在'); + if (!['DRAFT', 'WAITING', 'SCHEDULED'].includes(job.status)) throw new Error('记录已进入执行或历史阶段,当前不能覆盖内容'); + if (await confirm(`服务器当前标题:${job.title}\n简介:${job.description}\n话题:${job.tags}\n\n你的输入:${formFor(row).elements.title.value}\n\n确认以这个服务器版本作为保存基准?输入会保留,之后仍须点击保存。`)) { + state(row).version = job.updated_at; row.dataset.updatedAt = job.updated_at; + label(row, '已核对服务器版本 · 当前输入尚未保存'); reconcile.hidden = true; + } + } catch (error) { label(row, error.message); } + finally { reconcile.disabled = false; } + } + const open = event.target.closest('[data-open-content-editor]'); if (open) openEditor(open.closest('[data-publish-row]')); + if (event.target.closest('[data-close-content-editor]') || event.target === backdrop) closeEditor(); + for (const [hook, dialog] of [['data-close-ai-preview', 'data-ai-preview-dialog'], ['data-close-cover', 'data-cover-dialog'], ['data-close-events', 'data-events-dialog'], ['data-close-policy', 'data-policy-dialog']]) if (event.target.closest(`[${hook}]`)) document.querySelector(`[${dialog}]`).close(); + }); + document.addEventListener('keydown', event => { + if (!activeRow || document.querySelector('dialog[open]')) return; + if (event.key === 'Escape') { event.preventDefault(); closeEditor(); } + if (event.key === 'Tab') { + const controls = Array.from(formFor(activeRow).querySelectorAll('input,select,textarea,button,a[href]')).filter(item => !item.disabled && item.getClientRects().length); + const first = controls[0], last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + } + }); + window.addEventListener('beforeunload', event => { if (Array.from(document.querySelectorAll('[data-section="content"]')).some(isLocked)) { event.preventDefault(); event.returnValue = ''; } }); + document.querySelectorAll('[data-section="content"]').forEach(state); + return { save, saveIds, isDirty, isLocked, observe, acceptCover, version: row => state(row)?.version, openEditor, closeEditor, leaveEditor, confirm, preview, pickCover, events, previewPolicy, dateGroups, setScheduleView }; +}; diff --git a/app/static/js/review-workspace.js b/app/static/js/review-workspace.js new file mode 100644 index 0000000..048a539 --- /dev/null +++ b/app/static/js/review-workspace.js @@ -0,0 +1,92 @@ +(() => { + const workspace = document.querySelector('[data-review-workspace]'); + if (!workspace) return; + const form = document.querySelector('#clip-review-form'); + const list = document.querySelector('#review-candidate-list'); + const saveState = document.querySelector('#clip-save-state'); + const cards = () => Array.from(form.querySelectorAll('[data-clip-card]')); + let activeId = cards()[0]?.dataset.clipId; + workspace.dataset.view = 'player'; + const bulk = form.querySelector('[data-clip-bulk-select]'); + if (bulk) list.before(bulk); + function select(id) { + activeId = id; + cards().forEach(card => { card.hidden = card.dataset.clipId !== id; }); + list.querySelectorAll('[data-candidate-id]').forEach(row => { + const selected = row.dataset.candidateId === id; + row.classList.toggle('is-current', selected); + row.querySelector('button').setAttribute('aria-current', selected ? 'true' : 'false'); + }); + const card = cards().find(card => card.dataset.clipId === id); + const caption = document.querySelector('#clip-preview-caption'); + if (card && caption) caption.textContent = `${card.querySelector('[name=title]').value} · ${card.querySelector('[name=start_time]').value}–${card.querySelector('[name=end_time]').value}`; + const video = document.querySelector('#clip-preview-video'); + if (video) { + video.pause(); + if (card && Number.isFinite(video.duration)) { + video.currentTime = Math.min(video.duration, Number(card.dataset.startSeconds || 0)); + activePreviewEndSeconds = Number(card.dataset.endSeconds || 0); + } + } + } + function render() { + list.replaceChildren(); + cards().forEach((card, index) => { + const row = document.createElement('div'); + row.className = 'review-candidate'; + row.dataset.candidateId = card.dataset.clipId; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = card.querySelector('[name=enabled]').checked; + checkbox.disabled = form.dataset.busy === 'true'; + checkbox.setAttribute('aria-label', `保留候选 ${index + 1}`); + checkbox.addEventListener('change', () => { + const input = card.querySelector('[name=enabled]'); + input.checked = checkbox.checked; + input.dispatchEvent(new Event('change', {bubbles:true})); + }); + const button = document.createElement('button'); + button.type = 'button'; + const title = document.createElement('strong'); + title.textContent = `${String(index + 1).padStart(2,'0')} · ${card.querySelector('[name=title]').value}`; + const time = document.createElement('small'); + time.textContent = `${card.querySelector('[name=start_time]').value}–${card.querySelector('[name=end_time]').value}`; + button.append(title,time); + button.addEventListener('click', () => select(card.dataset.clipId)); + row.append(checkbox,button); + list.append(row); + }); + if (!cards().some(card => card.dataset.clipId === activeId)) activeId = cards()[0]?.dataset.clipId; + select(activeId); + } + form.addEventListener('change', render); + form.addEventListener('input', () => { + // Updating text must preserve cursor and current editor focus. + list.querySelectorAll('[data-candidate-id]').forEach(row => { + const card = cards().find(card => card.dataset.clipId === row.dataset.candidateId); + if (card) row.querySelector('strong').textContent = card.querySelector('[name=title]').value; + }); + }); + new MutationObserver(render).observe(form, {childList:true}); + document.addEventListener('clip-review-state', () => { + if (saveState) saveState.textContent = form.dataset.busy === 'true' ? '正在处理…' : form.dataset.dirty === 'true' ? '有未保存修改' : '选择已保存'; + list.querySelectorAll('[data-candidate-id]').forEach(row => { + const card = cards().find(item => item.dataset.clipId === row.dataset.candidateId); + const input = row.querySelector('input'); + input.disabled = form.dataset.busy === 'true'; + if (card) { + input.checked = card.querySelector('[name=enabled]').checked; + row.querySelector('small').textContent = `${card.querySelector('[name=start_time]').value}–${card.querySelector('[name=end_time]').value}`; + } + }); + }); + workspace.querySelectorAll('[data-review-view]').forEach(button => button.addEventListener('click', () => { + workspace.dataset.view = button.dataset.reviewView; + workspace.querySelectorAll('[data-review-view]').forEach(item => item.setAttribute('aria-pressed', String(item === button))); + })); + window.addEventListener('beforeunload', event => { + if (form.dataset.dirty !== 'true') return; + event.preventDefault(); event.returnValue = ''; + }); + render(); +})(); diff --git a/app/static/js/studio-shell.js b/app/static/js/studio-shell.js new file mode 100644 index 0000000..da17650 --- /dev/null +++ b/app/static/js/studio-shell.js @@ -0,0 +1,221 @@ +(() => { + "use strict"; + const byId = id => document.getElementById(id); + const focusable = root => Array.from(root.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),[tabindex="0"]')).filter(node => !node.closest('[hidden]') && node.getClientRects().length); + const menu = byId('studio-navigation'); + const menuToggle = byId('studio-menu-toggle'); + const menuBackdrop = document.querySelector('[data-close-navigation]'); + let menuReturnFocus = null; + function closeNavigation() { + document.body.classList.remove('studio-navigation-open'); + menuToggle?.setAttribute('aria-expanded', 'false'); + if (menuBackdrop) menuBackdrop.hidden = true; + menuReturnFocus?.focus(); + menuReturnFocus = null; + } + function openNavigation() { + menuReturnFocus = document.activeElement; + document.body.classList.add('studio-navigation-open'); + menuToggle.setAttribute('aria-expanded', 'true'); + menuBackdrop.hidden = false; + focusable(menu)[0]?.focus(); + } + menuToggle?.addEventListener('click', () => menuToggle.getAttribute('aria-expanded') === 'true' ? closeNavigation() : openNavigation()); + menuBackdrop?.addEventListener('click', closeNavigation); + window.matchMedia('(min-width: 901px)').addEventListener('change', event => { if (event.matches) closeNavigation(); }); + + document.querySelectorAll('label.file-upload-button[for]').forEach(label => { + label.setAttribute('role', 'button'); label.tabIndex = 0; + label.addEventListener('keydown', event => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); byId(label.htmlFor)?.click(); + }); + }); + + window.StudioUI = { + confirm({ title = '确认操作', message = '', confirmLabel = '确认', danger = false } = {}) { + return new Promise(resolve => { + const dialog = document.createElement('dialog'); + dialog.className = 'studio-confirm-dialog'; + const heading = document.createElement('h2'); heading.textContent = title; + const text = document.createElement('p'); text.textContent = message; + const actions = document.createElement('div'); actions.className = 'button-row'; + const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'secondary-button'; cancel.textContent = '取消'; + const confirm = document.createElement('button'); confirm.type = 'button'; confirm.className = 'primary-button'; confirm.textContent = confirmLabel; + if (danger) confirm.classList.add('danger-button'); + heading.id = 'studio-confirm-title'; dialog.setAttribute('aria-labelledby', heading.id); + actions.append(cancel, confirm); dialog.append(heading, text, actions); document.body.append(dialog); + const previous = document.activeElement; + let accepted = false; + cancel.addEventListener('click', () => dialog.close()); + confirm.addEventListener('click', () => { accepted = true; dialog.close(); }); + dialog.addEventListener('close', () => { dialog.remove(); if (previous?.isConnected) previous.focus(); resolve(accepted); }, { once: true }); + dialog.showModal(); cancel.focus(); + }); + }, + }; + + // Existing workspaces keep their close handlers; this layer adds keyboard focus behavior. + let activeOverlay = null; + let overlayReturnFocus = null; + const overlaySelectors = '.modal-backdrop:not([hidden]) [role="dialog"],.schedule-drawer:not([hidden]),.account-drawer:not([hidden]),.transcript-drawer:not([hidden]),[data-studio-dialog]:not([hidden])'; + function syncOverlay() { + const candidates = Array.from(document.querySelectorAll(overlaySelectors)).filter(node => node.getClientRects().length); + const next = candidates.at(-1) || null; + if (next === activeOverlay) return; + if (!next) { + if (overlayReturnFocus?.isConnected && overlayReturnFocus.getClientRects().length) overlayReturnFocus.focus(); + activeOverlay = null; overlayReturnFocus = null; return; + } + if (!activeOverlay) overlayReturnFocus = document.activeElement; + activeOverlay = next; + next.setAttribute('role', 'dialog'); next.setAttribute('aria-modal', 'true'); + if (!next.hasAttribute('aria-label') && !next.hasAttribute('aria-labelledby')) next.setAttribute('aria-label', next.querySelector('h2,h3')?.textContent || '编辑面板'); + if (!next.contains(document.activeElement)) focusable(next)[0]?.focus(); + } + let overlayFrame = 0; + new MutationObserver(records => { + if (!records.some(record => record.type === 'attributes' && (record.attributeName === 'hidden' || record.attributeName === 'class'))) return; + if (!overlayFrame) overlayFrame = requestAnimationFrame(() => { overlayFrame = 0; syncOverlay(); }); + }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['hidden', 'class'] }); + document.addEventListener('keydown', event => { + const navigationOpen = document.body.classList.contains('studio-navigation-open'); + const root = navigationOpen ? menu : activeOverlay; + if (!root || document.querySelector('dialog[open]')) return; + if (event.key === 'Escape') { + if (navigationOpen) { event.preventDefault(); closeNavigation(); } + else { + const close = root.querySelector('[aria-label^="关闭"],[data-close-schedule-drawer],[data-close-account-drawer],#close-ai-config,#cancel-cut-edit'); + if (close) { event.preventDefault(); close.click(); } + } + } else if (event.key === 'Tab') { + const nodes = focusable(root); + const first = nodes[0], last = nodes.at(-1); + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) { event.preventDefault(); first.focus(); } + } + }); + + const search = byId('studio-task-search'); + const results = byId('studio-search-results'); + let searchTimer = 0, searchController = null, searchGeneration = 0; + function closeSearch() { window.clearTimeout(searchTimer); ++searchGeneration; searchController?.abort(); if (results) results.hidden = true; search?.setAttribute('aria-expanded', 'false'); } + async function searchTasks() { + const query = search.value.trim(); + const generation = ++searchGeneration; + searchController?.abort(); + if (!query) { closeSearch(); return; } + searchController = new AbortController(); + results.hidden = false; search.setAttribute('aria-expanded', 'true'); + const status = results.querySelector('[role="status"]'); + const items = results.querySelector('[data-search-items]'); + status.textContent = '正在搜索…'; items.replaceChildren(); + try { + const payload = await window.apiFetch(`/api/ui/tasks?q=${encodeURIComponent(query)}`, { signal: searchController.signal }); + if (generation !== searchGeneration) return; + const tasks = payload.tasks || []; + status.textContent = tasks.length ? `找到 ${payload.pagination?.total ?? tasks.length} 个制作任务` : '没有匹配的制作任务,请换个关键词。'; + for (const task of tasks.slice(0, 6)) { + const link = document.createElement('a'); link.className = 'studio-search-result'; link.href = `/tasks/${encodeURIComponent(task.id)}`; + const title = document.createElement('strong'); title.textContent = task.title; + const note = document.createElement('small'); note.textContent = [task.platform_label, task.ui?.stage_label || task.status_label].filter(Boolean).join(' · '); + link.append(title, note); items.append(link); + } + const all = document.createElement('a'); all.className = 'studio-search-all'; all.href = `/tasks?q=${encodeURIComponent(query)}`; all.textContent = '在制作任务中查看全部结果'; items.append(all); + } catch (error) { if (generation === searchGeneration && error.name !== 'AbortError') status.textContent = '搜索暂时不可用,请按回车进入任务列表重试。'; } + } + search?.addEventListener('input', () => { window.clearTimeout(searchTimer); ++searchGeneration; searchController?.abort(); searchTimer = window.setTimeout(searchTasks, 220); }); + search?.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' && !results.hidden) { event.preventDefault(); focusable(results)[0]?.focus(); } + if (event.key === 'Escape') { event.preventDefault(); closeSearch(); } + }); + results?.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); closeSearch(); search.focus(); } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); const nodes = focusable(results); const index = nodes.indexOf(document.activeElement); + if (event.key === 'ArrowUp' && index === 0) search.focus(); else nodes[Math.max(0, Math.min(nodes.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))]?.focus(); + } + }); + document.addEventListener('click', event => { if (!event.target.closest('.studio-search')) closeSearch(); }); + + function initializeTabs(group) { + const buttons = Array.from(group.querySelectorAll('[data-studio-tab]')); + if (!buttons.length) return; + const key = group.dataset.tabQuery || 'tab'; + const values = new Set(buttons.map(button => button.dataset.studioTab)); + const activate = (value, updateUrl = false) => { + if (!values.has(value)) value = buttons[0].dataset.studioTab; + buttons.forEach(button => { + const active = button.dataset.studioTab === value; + button.setAttribute('aria-selected', String(active)); button.tabIndex = active ? 0 : -1; + const panel = byId(button.getAttribute('aria-controls')); if (panel) panel.hidden = !active; + }); + if (updateUrl) { const url = new URL(location.href); url.searchParams.set(key, value); history.replaceState(null, '', url); } + document.dispatchEvent(new CustomEvent('studio-tab-change', { detail: { group: group.id, value } })); + }; + buttons.forEach(button => button.addEventListener('click', () => activate(button.dataset.studioTab, true))); + group.addEventListener('keydown', event => { + const index = buttons.indexOf(document.activeElement); + if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length; + activate(buttons[next].dataset.studioTab, true); buttons[next].focus(); + }); + activate(new URLSearchParams(location.search).get(key) || group.dataset.defaultTab); + } + document.querySelectorAll('[data-studio-tabs]').forEach(initializeTabs); + + const planForm = byId('studio-plan-form'); + let plans = [], selectedPlan = null, savedPlan = '', plansLoaded = false, planBusy = false; + const planStatus = byId('studio-plan-status'); + const planDirty = () => !!selectedPlan && JSON.stringify({ name: planForm.elements.name.value, prompt_text: planForm.elements.prompt_text.value }) !== savedPlan; + function updatePlanState() { + if (!planForm) return; + byId('studio-plan-save').disabled = !selectedPlan || !planDirty() || planBusy; + planForm.querySelectorAll('input,textarea').forEach(node => { node.disabled = !selectedPlan || planBusy; }); + document.querySelectorAll('[data-plan-id]').forEach(node => { node.disabled = planBusy; }); + } + async function selectPlan(plan) { + if (planBusy) return; + if (planDirty() && !(await window.StudioUI.confirm({ title: '放弃尚未保存的方案修改?', message: '当前修改还没有保存。切换后将重新载入所选方案。', confirmLabel: '放弃并切换' }))) return; + selectedPlan = plan; planForm.elements.name.value = plan.name; planForm.elements.prompt_text.value = plan.prompt_text || ''; + savedPlan = JSON.stringify({ name: plan.name, prompt_text: plan.prompt_text || '' }); + byId('studio-plan-meta').textContent = `方案 ${plan.slot} · ${plan.id} · 更新于 ${plan.updated_at || '尚未更新'}`; + document.querySelectorAll('[data-plan-id]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.planId === plan.id))); + planStatus.textContent = '已载入共享方案。修改后需点击“保存共享方案”,新任务选用更新内容。'; updatePlanState(); + } + async function loadPlans() { + if (!planForm || plansLoaded) return; + plansLoaded = true; planStatus.textContent = '正在读取制作方案…'; + try { + plans = await window.apiFetch('/api/ai-prompt-presets'); + const list = byId('studio-plan-list'); list.replaceChildren(); + plans.forEach(plan => { const button = document.createElement('button'); button.type = 'button'; button.className = 'studio-plan-choice'; button.dataset.planId = plan.id; button.setAttribute('aria-pressed', 'false'); const label = document.createElement('strong'); label.textContent = plan.name; const note = document.createElement('small'); note.textContent = `方案 ${plan.slot}${plan.is_default ? ' · 默认' : ''}`; button.append(label, note); button.addEventListener('click', () => selectPlan(plan)); list.append(button); }); + if (plans.length) await selectPlan(plans[0]); else planStatus.textContent = '暂时没有可编辑的制作方案。'; + } catch (error) { plansLoaded = false; planStatus.textContent = `读取失败:${error.message}。重新打开此标签可重试。`; } + } + document.addEventListener('studio-tab-change', event => { if (event.detail.group === 'studio-settings-tabs' && event.detail.value === 'plans') void loadPlans(); }); + if (planForm && !byId('studio-settings-plans').hidden) void loadPlans(); + planForm?.addEventListener('input', () => { planStatus.textContent = '有未保存的方案修改'; updatePlanState(); }); + planForm?.addEventListener('submit', async event => { + event.preventDefault(); if (!planDirty() || planBusy || !planForm.reportValidity()) return; + const payload = { name: planForm.elements.name.value.trim(), prompt_text: planForm.elements.prompt_text.value.trim() }; + planBusy = true; updatePlanState(); + if (!(await window.StudioUI.confirm({ title: '保存共享制作方案', message: '这会更新共享方案,供今后新建制作选用。已经冻结的任务继续使用原方案。当前生产策略的启用与回退仍须在改进试验中另行确认。', confirmLabel: '保存共享方案' }))) { planBusy = false; updatePlanState(); return; } + planStatus.textContent = '正在保存…'; + try { + const data = await window.apiFetch(`/api/ai-prompt-presets/${encodeURIComponent(selectedPlan.id)}`, { method: 'PATCH', body: JSON.stringify(payload) }); + const index = plans.findIndex(plan => plan.id === selectedPlan.id); plans[index] = data.preset; + selectedPlan = data.preset; planForm.elements.name.value = data.preset.name; planForm.elements.prompt_text.value = data.preset.prompt_text || ''; savedPlan = JSON.stringify({ name: data.preset.name, prompt_text: data.preset.prompt_text || '' }); + document.querySelector(`[data-plan-id="${CSS.escape(selectedPlan.id)}"] strong`).textContent = selectedPlan.name; + byId('studio-plan-meta').textContent = `方案 ${selectedPlan.slot} · 更新于 ${selectedPlan.updated_at}`; + planStatus.textContent = '共享方案已保存,已经冻结的任务保持原方案。'; + } catch (error) { planStatus.textContent = `保存失败:${error.message}。修改已保留,可重试。`; } + finally { planBusy = false; updatePlanState(); } + }); + let aiDirty = false; + document.addEventListener('studio-ai-config-saved', () => { aiDirty = false; }); + byId('ai-config-form')?.addEventListener('input', () => { aiDirty = true; }); + new MutationObserver(() => { if (/配置.*已保存|配置保存成功|配置已更新|已保存.*配置/.test(byId('ai-config-result')?.textContent || '')) aiDirty = false; }).observe(byId('ai-config-result') || document.createElement('span'), { childList: true, subtree: true, characterData: true }); + window.addEventListener('beforeunload', event => { if (!planDirty() && !aiDirty) return; event.preventDefault(); event.returnValue = ''; }); +})(); diff --git a/app/static/js/subtitle-editor.js b/app/static/js/subtitle-editor.js index ca5c993..f3fd33a 100644 --- a/app/static/js/subtitle-editor.js +++ b/app/static/js/subtitle-editor.js @@ -61,6 +61,10 @@ selectedRegion: null, saveTimer: null, saving: false, + savePromise: null, + switching: false, + busyAction: false, + batchRunning: false, dirty: false, changeVersion: 0, requestToken: 0, @@ -122,6 +126,7 @@ } function mutate(callback) { + if (state.switching || state.busyAction || state.batchRunning) return; state.undo.push(cueSnapshot()); if (state.undo.length > 50) state.undo.shift(); state.redo = []; @@ -361,12 +366,43 @@ } } - async function loadTrack(trackId) { + function syncEditorAccess() { + root.inert = state.switching || state.busyAction || state.batchRunning; + elements.track.disabled = root.inert || state.tracks.length === 0; + } + + async function withTrackLock(action) { + if (state.switching || state.busyAction || state.batchRunning || !state.track) return; + state.busyAction = true; + syncEditorAccess(); + try { + if (!(await saveRevision(true))) return; + await action(); + } finally { + state.busyAction = false; + syncEditorAccess(); + } + } + + async function loadTrack(trackId, internal = false) { + if (state.switching || (!internal && (state.busyAction || state.batchRunning))) { + if (state.track) elements.track.value = state.track.id; + return false; + } + const previousTrackId = state.track?.id; + state.switching = true; + syncEditorAccess(); + if (!(await saveRevision(true))) { + if (previousTrackId) elements.track.value = previousTrackId; + state.switching = false; + syncEditorAccess(); + return false; + } const token = ++state.requestToken; window.clearTimeout(state.saveTimer); setStatus("正在载入毫秒级字幕…", "blue"); const track = state.tracks.find((item) => item.id === trackId); - if (!track) return; + if (!track) { state.switching = false; syncEditorAccess(); return false; } try { const first = await api(`/api/subtitles/tracks/${encodeURIComponent(trackId)}/cues?offset=0&limit=${PAGE_SIZE}`); const cues = [...(first.cues || [])]; @@ -397,8 +433,14 @@ updateUndoButtons(); setStatus(`已载入 ${state.cues.length} 条,自动保存已开启`, "green"); loadWaveform(token); + return true; } catch (error) { - setStatus(error.message, "red"); + if (previousTrackId) elements.track.value = previousTrackId; + setStatus(`载入失败,当前编辑已保留:${error.message}`, "red"); + return false; + } finally { + state.switching = false; + syncEditorAccess(); } } @@ -457,41 +499,52 @@ } async function saveRevision(force) { - if (!state.dirty || state.saving || !state.track || !state.revision) return; + while (state.savePromise) { + if (!(await state.savePromise)) return false; + } + if (!state.dirty) return true; + if (!state.track || !state.revision) return false; window.clearTimeout(state.saveTimer); state.saving = true; const version = state.changeVersion; + const trackId = state.track.id; const cues = cueSnapshot(); const baseRevisionId = state.revision.id; - setStatus("正在自动保存新 revision…", "blue"); - try { - const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/revisions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ base_revision_id: baseRevisionId, cues, note: force ? "手动立即保存" : "字幕编辑器自动保存" }), - }); - state.revision = payload.revision; - if (version === state.changeVersion) { - state.cues = (payload.revision.cues || []).map((cue) => ({ ...cue })); - state.dirty = false; - state.selectedIds.clear(); - updateAiButton(); - elements.save.disabled = true; - applySearch(); - setStatus(`Revision ${state.revision.revision_number} 已保存`, "green"); - } else { - state.dirty = true; - setStatus("保存期间又有修改,正在继续保存…", "amber"); - } - renderRevisionMeta(); - } catch (error) { - setStatus(error.status === 409 ? "版本已变化,请重新选择字幕轨后再编辑" : `保存失败:${error.message}`, "red"); - } finally { - state.saving = false; - if (state.dirty && version !== state.changeVersion) { - state.saveTimer = window.setTimeout(() => saveRevision(false), 300); + setStatus("正在保存当前字幕…", "blue"); + state.savePromise = (async () => { + try { + const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(trackId)}/revisions`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ base_revision_id: baseRevisionId, cues, note: force ? "手动立即保存" : "字幕编辑器自动保存" }), + }); + if (state.track?.id !== trackId) return false; + state.revision = payload.revision; + if (version === state.changeVersion) { + state.cues = (payload.revision.cues || []).map(cue => ({ ...cue })); + state.dirty = false; + state.selectedIds.clear(); + updateAiButton(); + elements.save.disabled = true; + applySearch(); + setStatus(`第 ${state.revision.revision_number} 版已保存`, "green"); + } else { + setStatus("保存期间有新修改,继续保存…", "amber"); + } + renderRevisionMeta(); + return true; + } catch (error) { + setStatus(error.status === 409 ? "版本冲突:未保存内容已保留,请另开页面核对最新版本" : `保存失败,编辑内容已保留:${error.message}`, "red"); + return false; } + })(); + const ok = await state.savePromise; + state.savePromise = null; + state.saving = false; + if (ok && state.dirty) { + if (force) return saveRevision(true); + state.saveTimer = window.setTimeout(() => saveRevision(false), 300); } + return ok; } function selectedCues() { @@ -583,8 +636,7 @@ mutate(() => state.cues.forEach((cue) => { cue.text = cue.text.split(search).join(replacement); })); }); - elements.approve.addEventListener("click", async () => { - if (state.dirty) await saveRevision(false); + elements.approve.addEventListener("click", () => withTrackLock(async () => { if (state.dirty || !state.revision) return; try { const payload = await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/approve`, { @@ -598,10 +650,9 @@ } catch (error) { setStatus(`审核失败:${error.message}`, "red"); } - }); + })); - elements.aiSuggest?.addEventListener("click", async () => { - if (state.dirty) await saveRevision(false); + elements.aiSuggest?.addEventListener("click", () => withTrackLock(async () => { const cueIds = [...state.selectedIds]; if (state.dirty || !state.revision || !cueIds.length) return; elements.aiSuggest.disabled = true; @@ -620,7 +671,7 @@ } finally { updateAiButton(); } - }); + })); function renderAiSuggestionDiff() { const diff = state.suggestion?.diff || []; @@ -651,7 +702,7 @@ state.suggestion = null; }); - elements.aiAccept?.addEventListener("click", async () => { + elements.aiAccept?.addEventListener("click", () => withTrackLock(async () => { if (!state.suggestion || !state.revision) return; const cueIds = [...elements.aiDiffList.querySelectorAll("[data-ai-diff-cue]:checked")].map((input) => input.value); if (!cueIds.length) return setStatus("请至少勾选一条 AI 文字建议", "amber"); @@ -667,15 +718,15 @@ ); state.suggestion = null; elements.aiPanel.hidden = true; - await loadTrack(state.track.id); + await loadTrack(state.track.id, true); setStatus(`已接受 ${cueIds.length} 条建议并创建人工草稿 Revision ${payload.revision.revision_number}`, "green"); } catch (error) { setStatus(`接受 AI 建议失败:${error.message}`, "red"); elements.aiAccept.disabled = false; } - }); + })); - elements.importFile.addEventListener("change", async () => { + elements.importFile.addEventListener("change", () => withTrackLock(async () => { const file = elements.importFile.files?.[0]; if (!file || !state.track) return; const form = new FormData(); @@ -683,13 +734,13 @@ setStatus(`正在导入 ${file.name}…`, "blue"); try { await api(`/api/subtitles/tracks/${encodeURIComponent(state.track.id)}/import`, { method: "POST", body: form }); - await loadTrack(state.track.id); + await loadTrack(state.track.id, true); } catch (error) { setStatus(`导入失败:${error.message}`, "red"); } finally { elements.importFile.value = ""; } - }); + })); elements.exports.forEach(([format, button]) => button.addEventListener("click", () => { if (!state.track || !state.revision) return; @@ -705,6 +756,8 @@ batch.label.textContent = `${job.status_label || job.status} · ${progress}%`; batch.message.textContent = job.error_message || job.message || "字幕任务状态已更新"; const active = job.status === "queued" || job.status === "running"; + state.batchRunning = active; + syncEditorAccess(); batch.cancel.hidden = !active; batch.retry.hidden = !(job.status === "failed" || job.status === "cancelled"); batch.approve.disabled = active; @@ -728,8 +781,7 @@ } } - batch.approve?.addEventListener("click", async () => { - if (state.dirty) await saveRevision(false); + batch.approve?.addEventListener("click", () => withTrackLock(async () => { if (state.dirty) return; if (!window.confirm("确认审核所有切片的当前字幕版本并批量烧录吗?全部验证通过后流水线会自动继续。")) return; batch.approve.disabled = true; @@ -748,9 +800,9 @@ batch.approve.disabled = false; if (batch.skip) batch.skip.disabled = false; } - }); + })); - batch.skip?.addEventListener("click", async () => { + batch.skip?.addEventListener("click", () => withTrackLock(async () => { if (!window.confirm("确认跳过字幕并进入片段审核吗?审核保存后才会同步发送中心。")) return; if (batch.skip) batch.skip.disabled = true; try { @@ -761,7 +813,7 @@ batch.panel.hidden = false; if (batch.skip) batch.skip.disabled = false; } - }); + })); batch.cancel?.addEventListener("click", async () => { if (!batch.jobId || !window.confirm("确认取消当前字幕烧录吗?已完成并验证的切片会保留,可稍后重试缺失部分。")) return; diff --git a/app/static/js/subtitle-workflow.js b/app/static/js/subtitle-workflow.js new file mode 100644 index 0000000..e421a6a --- /dev/null +++ b/app/static/js/subtitle-workflow.js @@ -0,0 +1,426 @@ +const subtitleStyleForm = document.querySelector("#subtitle-style-form"); +const subtitleStyleResult = document.querySelector("#subtitle-style-result"); + +if (subtitleStyleForm) { + subtitleStyleForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitButton = subtitleStyleForm.querySelector("button[type='submit']"); + const formData = new FormData(subtitleStyleForm); + const payload = Object.fromEntries(formData.entries()); + payload.font_size = Number(payload.font_size || 42); + payload.outline_width = Number(payload.outline_width || 3); + payload.shadow_depth = Number(payload.shadow_depth || 1); + payload.safe_area_percent = Number(payload.safe_area_percent || 5); + payload.speaker_styles = { + 主播: { font_color: payload.speaker_host_color || "#ffffff" }, + 嘉宾: { font_color: payload.speaker_guest_color || "#ffd60a" }, + }; + delete payload.speaker_host_color; + delete payload.speaker_guest_color; + payload.shadow_enabled = Boolean(subtitleStyleForm.elements.shadow_enabled?.checked); + if (submitButton) submitButton.disabled = true; + if (subtitleStyleResult) subtitleStyleResult.textContent = "正在保存字幕样式..."; + + try { + const response = await fetch("/api/tasks/subtitle-style", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "字幕样式保存失败"); + } + if (subtitleStyleResult) subtitleStyleResult.textContent = data.message || "字幕样式已保存。"; + } catch (error) { + if (subtitleStyleResult) subtitleStyleResult.textContent = `保存失败:${error.message}`; + } finally { + if (submitButton) submitButton.disabled = false; + } + }); +} + +document.querySelectorAll("[data-render-subtitle]").forEach((button) => { + button.addEventListener("click", async () => { + const card = button.closest("[data-subtitle-output-card]"); + if (!card) return; + const taskId = card.dataset.taskId; + const outputId = card.dataset.outputId; + const statusNode = card.querySelector("[data-subtitle-status]"); + const errorNode = card.querySelector("[data-subtitle-error]"); + const originalText = button.textContent; + button.disabled = true; + button.textContent = "正在入队..."; + if (statusNode) statusNode.textContent = "字幕排队中"; + if (errorNode) errorNode.textContent = ""; + + try { + const response = await fetch(`/api/tasks/${taskId}/output-clips/${outputId}/subtitles`, { method: "POST" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "自动加字幕失败"); + } + if (errorNode) errorNode.textContent = data.message || "字幕任务已加入队列。"; + button.textContent = "后台烧录中"; + await pollSubtitleWorkflowJob(data.job_id, statusNode, errorNode); + window.location.reload(); + } catch (error) { + if (statusNode) statusNode.textContent = "字幕失败"; + if (errorNode) errorNode.textContent = `自动加字幕失败:${error.message}`; + } finally { + button.disabled = false; + button.textContent = originalText; + } + }); +}); + +async function pollSubtitleWorkflowJob(jobId, statusNode, messageNode) { + if (!jobId) throw new Error("后台没有返回字幕 job id"); + while (true) { + const response = await fetch(`/api/tasks/jobs/${encodeURIComponent(jobId)}`); + const job = await response.json(); + if (!response.ok) throw new Error(job.detail || "读取字幕任务状态失败"); + if (statusNode) statusNode.textContent = `${job.status_label || job.status} · ${Number(job.progress || 0)}%`; + if (messageNode) messageNode.textContent = job.message || "字幕任务处理中"; + if (job.status === "completed") return job; + if (job.status === "failed" || job.status === "cancelled") { + throw new Error(job.error_message || job.message || "字幕任务未完成"); + } + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + } +} + +const cutEditModal = document.querySelector("#cut-edit-modal"); +const closeCutEditButton = document.querySelector("#close-cut-edit"); +const cutEditVideo = document.querySelector("#cut-edit-video"); +const cutEditCaption = document.querySelector("#cut-edit-caption"); +const playCutPreviewButton = document.querySelector("#play-cut-preview"); +const saveCutEditButton = document.querySelector("#save-cut-edit"); +const cutEditSaveMessage = document.querySelector("#cut-edit-save-message"); +const trimFrameTrack = document.querySelector("#trim-frame-track"); +const trimSlider = document.querySelector("#trim-slider"); +const trimStripPlayButton = document.querySelector("#trim-strip-play"); +const trimPlayhead = document.querySelector("#trim-playhead"); +const trimStartInput = document.querySelector("#trim-start-input"); +const trimEndInput = document.querySelector("#trim-end-input"); +const trimDurationLabel = document.querySelector("#trim-duration-label"); +const trimStartPosition = document.querySelector("#trim-start-position"); +const trimCurrentPosition = document.querySelector("#trim-current-position"); +const trimEndPosition = document.querySelector("#trim-end-position"); +const setTrimStartButton = document.querySelector("#set-trim-start"); +const setTrimEndButton = document.querySelector("#set-trim-end"); +let activeTrimState = null; +let isSyncingTrimSlider = false; + +function setCutEditMessage(message, tone = "info") { + if (!cutEditSaveMessage) return; + cutEditSaveMessage.textContent = message; + cutEditSaveMessage.dataset.tone = tone; +} + +function getTrimDuration() { + if (!activeTrimState) return 1; + const videoDuration = Number(cutEditVideo?.duration || 0); + return Math.max(1, videoDuration || activeTrimState.durationSeconds || activeTrimState.endSeconds || 1); +} + +function clampTrimSeconds(value, min, max) { + return Math.max(min, Math.min(Number(value) || 0, max)); +} + +function normalizeTrimRange(startSeconds, endSeconds) { + const duration = getTrimDuration(); + const minGap = Math.min(1, Math.max(0.1, duration / 10)); + let start = clampTrimSeconds(startSeconds, 0, Math.max(0, duration - minGap)); + let end = clampTrimSeconds(endSeconds, start + minGap, duration); + if (end - start < minGap) { + if (end >= duration) { + start = Math.max(0, duration - minGap); + end = duration; + } else { + end = Math.min(duration, start + minGap); + } + } + return { start, end }; +} + +function getTrimSliderInstance() { + return trimSlider?.noUiSlider || null; +} + +function updateTrimPlayhead() { + if (!activeTrimState || !trimPlayhead || !trimFrameTrack) return; + const duration = getTrimDuration(); + const currentSeconds = clampTrimSeconds(cutEditVideo?.currentTime || activeTrimState.startSeconds, 0, duration); + const playWidth = trimStripPlayButton?.offsetWidth || 72; + const trackWidth = trimFrameTrack.clientWidth || 1; + const usableWidth = Math.max(1, trackWidth - playWidth); + trimPlayhead.style.left = `${playWidth + (currentSeconds / duration) * usableWidth}px`; +} + +function updateTrimReadout() { + if (!activeTrimState) return; + const currentSeconds = clampTrimSeconds(cutEditVideo?.currentTime || activeTrimState.startSeconds, 0, getTrimDuration()); + if (trimStartInput) trimStartInput.value = secondsToTimeText(activeTrimState.startSeconds); + if (trimEndInput) trimEndInput.value = secondsToTimeText(activeTrimState.endSeconds); + if (trimDurationLabel) trimDurationLabel.textContent = `选中 ${secondsToTimeText(activeTrimState.endSeconds - activeTrimState.startSeconds)}`; + if (trimStartPosition) trimStartPosition.textContent = secondsToTimeText(activeTrimState.startSeconds); + if (trimCurrentPosition) trimCurrentPosition.textContent = secondsToTimeText(currentSeconds); + if (trimEndPosition) trimEndPosition.textContent = secondsToTimeText(activeTrimState.endSeconds); + updateTrimPlayhead(); +} + +function handleTrimSliderUpdate(values, handle, unencoded) { + if (!activeTrimState || isSyncingTrimSlider) return; + const start = Number(unencoded?.[0] ?? values?.[0]); + const end = Number(unencoded?.[1] ?? values?.[1]); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + const normalized = normalizeTrimRange(start, end); + activeTrimState.startSeconds = Math.round(normalized.start); + activeTrimState.endSeconds = Math.round(normalized.end); + updateTrimReadout(); +} + +function syncTrimSliderOptions() { + if (!activeTrimState || !trimSlider) return false; + if (!window.noUiSlider) { + trimSlider.dataset.disabled = "true"; + setCutEditMessage("剪切滑块组件加载失败,请刷新页面后再试。", "error"); + return false; + } + + const duration = getTrimDuration(); + const normalized = normalizeTrimRange(activeTrimState.startSeconds, activeTrimState.endSeconds); + activeTrimState.startSeconds = Math.round(normalized.start); + activeTrimState.endSeconds = Math.round(normalized.end); + activeTrimState.durationSeconds = duration; + trimSlider.dataset.disabled = "false"; + + const options = { + start: [activeTrimState.startSeconds, activeTrimState.endSeconds], + connect: [false, true, false], + behaviour: "tap-drag", + step: 1, + margin: Math.min(1, Math.max(0.1, duration / 10)), + range: { + min: 0, + max: duration, + }, + }; + + isSyncingTrimSlider = true; + if (trimSlider.noUiSlider) { + trimSlider.noUiSlider.updateOptions(options, false); + } else { + window.noUiSlider.create(trimSlider, options); + trimSlider.noUiSlider.on("update", handleTrimSliderUpdate); + trimSlider.noUiSlider.on("slide", (values, handle, unencoded) => { + if (!cutEditVideo || !activeTrimState) return; + const nextTime = Number(unencoded?.[handle] ?? values?.[handle]); + if (Number.isFinite(nextTime)) cutEditVideo.currentTime = clampTrimSeconds(nextTime, 0, getTrimDuration()); + }); + trimSlider.noUiSlider.on("change", () => { + setCutEditMessage("已更新这个片段里的入点 / 出点,点击保存后会写回片段审核。", "success"); + }); + } + trimSlider.noUiSlider.set([activeTrimState.startSeconds, activeTrimState.endSeconds]); + isSyncingTrimSlider = false; + updateTrimReadout(); + return true; +} + +function renderTrimEditor() { + syncTrimSliderOptions(); +} + +function updateTrimRange(startSeconds, endSeconds, seekTo = "start") { + if (!activeTrimState) return; + const normalized = normalizeTrimRange(startSeconds, endSeconds); + activeTrimState.startSeconds = Math.round(normalized.start); + activeTrimState.endSeconds = Math.round(normalized.end); + const slider = getTrimSliderInstance(); + if (slider) slider.set([activeTrimState.startSeconds, activeTrimState.endSeconds]); + updateTrimReadout(); + if (cutEditVideo) { + cutEditVideo.currentTime = seekTo === "end" ? activeTrimState.endSeconds : activeTrimState.startSeconds; + } +} + +function toggleCutEditModal(show) { + if (!cutEditModal) return; + if (show) { + cutEditModal.removeAttribute("hidden"); + document.body.style.overflow = "hidden"; + return; + } + cutEditModal.setAttribute("hidden", ""); + document.body.style.overflow = ""; + if (cutEditVideo) cutEditVideo.pause(); + activeTrimState = null; + isSyncingTrimSlider = false; +} + +document.querySelectorAll("[data-cut-edit-trigger]").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest("[data-subtitle-output-card]"); + const clipVideo = card?.querySelector(":scope > video"); + if (!card || !cutEditVideo || !clipVideo) return; + const absoluteStartSeconds = Number(card.dataset.startSeconds || 0); + const absoluteEndSeconds = Number(card.dataset.endSeconds || absoluteStartSeconds + 1); + const outputBaseStartSeconds = Number(card.dataset.outputBaseStartSeconds || card.dataset.startSeconds || 0); + const fallbackDuration = Math.max( + 1, + Number(card.dataset.durationSeconds || 0), + Number(clipVideo.duration || 0), + absoluteEndSeconds - outputBaseStartSeconds + ); + const localStartSeconds = clampTrimSeconds(absoluteStartSeconds - outputBaseStartSeconds, 0, fallbackDuration); + const localEndSeconds = clampTrimSeconds(absoluteEndSeconds - outputBaseStartSeconds, localStartSeconds + 1, fallbackDuration); + activeTrimState = { + card, + taskId: card.dataset.taskId || "", + clipId: card.dataset.clipId || "", + title: card.dataset.clipTitle || card.querySelector(".subtitle-output-body strong")?.textContent || "当前切片", + summary: card.dataset.clipSummary || "", + enabled: card.dataset.clipEnabled !== "0", + outputBaseStartSeconds, + durationSeconds: fallbackDuration, + startSeconds: localStartSeconds, + endSeconds: localEndSeconds, + }; + cutEditVideo.src = card.dataset.outputMediaUrl || clipVideo.currentSrc || clipVideo.src; + cutEditVideo.load(); + if (cutEditCaption) { + cutEditCaption.textContent = activeTrimState.title; + } + setCutEditMessage("当前只裁这个已生成片段:拖动黄色左右把手调整入点和出点。", "info"); + toggleCutEditModal(true); + renderTrimEditor(); + cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); + }); +}); + +if (closeCutEditButton) { + closeCutEditButton.addEventListener("click", () => toggleCutEditModal(false)); +} + +if (cutEditModal) { + cutEditModal.addEventListener("click", (event) => { + if (event.target === cutEditModal) toggleCutEditModal(false); + }); +} + +if (playCutPreviewButton && cutEditVideo) { + playCutPreviewButton.addEventListener("click", () => { + if (activeTrimState) { + cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); + } + cutEditVideo.play().catch(() => {}); + }); +} + +if (trimStripPlayButton && cutEditVideo) { + trimStripPlayButton.addEventListener("click", () => { + if (activeTrimState) { + cutEditVideo.currentTime = Math.max(0, activeTrimState.startSeconds); + } + cutEditVideo.play().catch(() => {}); + }); +} + +if (cutEditVideo) { + cutEditVideo.addEventListener("loadedmetadata", () => { + if (!activeTrimState) return; + activeTrimState.durationSeconds = getTrimDuration(); + const normalized = normalizeTrimRange(activeTrimState.startSeconds, activeTrimState.endSeconds); + activeTrimState.startSeconds = Math.round(normalized.start); + activeTrimState.endSeconds = Math.round(normalized.end); + cutEditVideo.currentTime = activeTrimState.startSeconds; + renderTrimEditor(); + }); + cutEditVideo.addEventListener("timeupdate", () => { + if (!activeTrimState) return; + updateTrimReadout(); + if (cutEditVideo.currentTime >= activeTrimState.endSeconds) { + cutEditVideo.pause(); + cutEditVideo.currentTime = activeTrimState.startSeconds; + } + }); +} + +if (trimStartInput) { + trimStartInput.addEventListener("change", () => { + updateTrimRange(timeTextToSeconds(trimStartInput.value), activeTrimState?.endSeconds || 1, "start"); + }); +} + +if (trimEndInput) { + trimEndInput.addEventListener("change", () => { + updateTrimRange(activeTrimState?.startSeconds || 0, timeTextToSeconds(trimEndInput.value), "end"); + }); +} + +if (setTrimStartButton && cutEditVideo) { + setTrimStartButton.addEventListener("click", () => { + updateTrimRange(cutEditVideo.currentTime, activeTrimState?.endSeconds || cutEditVideo.currentTime + 1, "start"); + }); +} + +if (setTrimEndButton && cutEditVideo) { + setTrimEndButton.addEventListener("click", () => { + updateTrimRange(activeTrimState?.startSeconds || 0, cutEditVideo.currentTime, "end"); + }); +} + +if (saveCutEditButton) { + saveCutEditButton.addEventListener("click", async () => { + if (!activeTrimState?.taskId || !activeTrimState?.clipId) { + setCutEditMessage("这条切片没有关联到候选片段,不能直接保存时间。", "error"); + return; + } + const originalText = saveCutEditButton.textContent; + saveCutEditButton.disabled = true; + saveCutEditButton.textContent = "保存中..."; + setCutEditMessage("正在保存这个片段内的入点和出点...", "info"); + const absoluteStartSeconds = Math.round(activeTrimState.outputBaseStartSeconds + activeTrimState.startSeconds); + const absoluteEndSeconds = Math.round(activeTrimState.outputBaseStartSeconds + activeTrimState.endSeconds); + try { + const response = await fetch(`/api/tasks/${activeTrimState.taskId}/clips/${activeTrimState.clipId}/update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: activeTrimState.title, + start_time: secondsToTimeText(absoluteStartSeconds), + end_time: secondsToTimeText(absoluteEndSeconds), + enabled: activeTrimState.enabled, + summary: activeTrimState.summary, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "保存失败"); + } + activeTrimState.card.dataset.startSeconds = String(absoluteStartSeconds); + activeTrimState.card.dataset.endSeconds = String(absoluteEndSeconds); + activeTrimState.card.dataset.durationSeconds = String(Math.max(1, absoluteEndSeconds - absoluteStartSeconds)); + activeTrimState.card.dataset.startTime = secondsToTimeText(absoluteStartSeconds); + activeTrimState.card.dataset.endTime = secondsToTimeText(absoluteEndSeconds); + setCutEditMessage("已保存。回到片段审核页重新生成切片后,会得到新的视频文件。", "success"); + } catch (error) { + setCutEditMessage(`保存失败:${error.message}`, "error"); + } finally { + saveCutEditButton.disabled = false; + saveCutEditButton.textContent = originalText; + } + }); +} + +window.addEventListener("resize", () => { + if (activeTrimState) updateTrimPlayhead(); +}); + +document.querySelectorAll(".js-demo-toast").forEach((button) => { + button.addEventListener("click", () => { + window.alert(button.dataset.message || "这个功能已经预留入口,后续会继续接入。"); + }); +}); diff --git a/app/static/js/system-settings.js b/app/static/js/system-settings.js new file mode 100644 index 0000000..75a1c48 --- /dev/null +++ b/app/static/js/system-settings.js @@ -0,0 +1,79 @@ +const aiConfigModal = document.querySelector("#ai-config-modal"); +const aiConfigForm = document.querySelector("#ai-config-form"); +const aiConfigResult = document.querySelector("#ai-config-result"); +const openAiConfigButton = document.querySelector("#open-ai-config"); +const closeAiConfigButton = document.querySelector("#close-ai-config"); +const cancelAiConfigButton = document.querySelector("#cancel-ai-config"); + +function toggleAiConfigModal(show) { + if (!aiConfigModal) return; + if (show) { + aiConfigModal.removeAttribute("hidden"); + document.body.style.overflow = "hidden"; + closeAiConfigButton?.focus(); + return; + } + aiConfigModal.setAttribute("hidden", ""); + document.body.style.overflow = ""; +} + +if (openAiConfigButton) { + openAiConfigButton.addEventListener("click", () => toggleAiConfigModal(true)); +} + +[closeAiConfigButton, cancelAiConfigButton].forEach((button) => { + if (!button) return; + button.addEventListener("click", () => toggleAiConfigModal(false)); +}); + +if (aiConfigModal) { + aiConfigModal.addEventListener("click", (event) => { + if (event.target === aiConfigModal) { + toggleAiConfigModal(false); + } + }); +} + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && aiConfigModal && !aiConfigModal.hidden) { + toggleAiConfigModal(false); + } +}); + +if (aiConfigForm) { + aiConfigForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitButton = aiConfigForm.querySelector("button[type='submit']"); + const formData = new FormData(aiConfigForm); + const payload = Object.fromEntries(formData.entries()); + payload.ai_request_timeout_seconds = Number(payload.ai_request_timeout_seconds || 120); + payload.ai_codex_timeout_seconds = Number(payload.ai_codex_timeout_seconds || 600); + payload.volcengine_asr_timeout_seconds = Number(payload.volcengine_asr_timeout_seconds || 300); + payload.ai_analysis_request_timeout_seconds = Number(payload.ai_analysis_request_timeout_seconds || 120); + payload.ai_publish_request_timeout_seconds = Number(payload.ai_publish_request_timeout_seconds || 120); + payload.ai_local_health_timeout_seconds = Number(payload.ai_local_health_timeout_seconds || 30); + + submitButton.disabled = true; + if (aiConfigResult) aiConfigResult.textContent = "正在保存三类 AI 接口配置..."; + + try { + const data = await window.apiFetch("/api/settings/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + document.dispatchEvent(new CustomEvent("studio-ai-config-saved")); + if (aiConfigResult) aiConfigResult.textContent = data.message || "保存成功。"; + window.setTimeout(() => { + toggleAiConfigModal(false); + window.location.reload(); + }, 500); + } catch (error) { + if (aiConfigResult) aiConfigResult.textContent = `保存失败:${error.message}`; + } finally { + submitButton.disabled = false; + } + }); +} + +// 发送中心页面行为由 publish-center.js 独立维护,避免全局脚本重复绑定。 diff --git a/app/static/js/task-actions.js b/app/static/js/task-actions.js new file mode 100644 index 0000000..7352028 --- /dev/null +++ b/app/static/js/task-actions.js @@ -0,0 +1,92 @@ +document.querySelectorAll("[data-sync-publish-task]").forEach((button) => { + button.addEventListener("click", async () => { + const taskId = button.dataset.taskId; + if (!taskId) return; + const originalText = button.textContent; + const preferSubtitled = button.dataset.preferSubtitled === "true"; + button.disabled = true; + button.textContent = "正在同步..."; + try { + const syncReviewedClips = button.dataset.syncReviewedClips === "true"; + let data; + if (syncReviewedClips) { + const clips = collectClipReviewPayload(); + if (!clips.some((clip) => clip.enabled)) { + throw new Error("请至少启用一条候选片段后再同步发送中心"); + } + button.textContent = "正在保存并生成..."; + showClipReviewMessage("正在保存审核选择;如选择有变化,将自动生成最新切片并同步...", "info"); + data = await window.apiFetch( + `/api/tasks/${encodeURIComponent(taskId)}/clips/sync-publish`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clips }), + }, + ); + } else { + data = await window.apiFetch( + `/api/publish/tasks/${encodeURIComponent(taskId)}/sync?prefer_subtitled=${preferSubtitled ? "true" : "false"}`, + { method: "POST" }, + ); + } + const summary = document.querySelector("[data-publish-link-summary]"); + if (summary) { + const heading = document.createElement("strong"); + const detail = document.createElement("span"); + heading.textContent = `发送中心关联:${data.link_state?.label || "同步完成"}`; + detail.textContent = data.message || ""; + summary.replaceChildren(heading, detail); + } + showClipReviewMessage(data.message || "发送中心同步完成。", data.status === "partial" ? "error" : "success"); + if (!document.querySelector("#process-result")) { + window.alert(data.message || "发送中心同步完成。"); + } + if (syncReviewedClips && data.status !== "partial") { + savedClipReviewPayload = collectClipReviewPayload(); + updateClipReviewActionState(); + const params = new URLSearchParams({ + task_id: taskId, + tab: "content", + publish_message: data.message || "发送中心同步完成。", + }); + window.location.assign(`/publish?${params.toString()}`); + } + } catch (error) { + const message = `同步发送中心失败:${error.message}`; + showClipReviewMessage(message, "error"); + if (!document.querySelector("#process-result")) window.alert(message); + } finally { + button.disabled = false; + button.textContent = originalText; + } + }); +}); + +document.querySelectorAll(".js-hide-task").forEach((button) => { + button.addEventListener("click", async () => { + const taskTitle = button.dataset.taskTitle || "这条任务"; + const confirmed = await window.StudioUI.confirm({title: "永久删除制作任务", confirmLabel: "永久删除", danger: true, message: `确认永久删除“${taskTitle}”吗?\n\n系统会永久删除 E 盘任务目录内的原片副本、音频、转写、切片、字幕、封面和发布包,删除后无法恢复。\n\n任务目录外的原始视频不会被删除。`}); + if (!confirmed) return; + + const originalText = button.textContent; + button.disabled = true; + button.textContent = "删除中..."; + + try { + const response = await fetch(`/api/tasks/${button.dataset.taskId}`, { method: "DELETE" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "永久删除失败"); + } + const externalNotice = data.external_source_preserved ? "\n\n任务目录外的原始视频已保留。" : ""; + window.alert(`${data.message || "任务已永久删除。"}${externalNotice}`); + window.location.reload(); + } catch (error) { + window.alert(`永久删除失败:${error.message}`); + } finally { + button.disabled = false; + button.textContent = originalText; + } + }); +}); diff --git a/app/static/js/task-progress.js b/app/static/js/task-progress.js new file mode 100644 index 0000000..62c44fc --- /dev/null +++ b/app/static/js/task-progress.js @@ -0,0 +1,1035 @@ +async function handleProcessAction(button) { + if (button.dataset.confirm && !window.confirm(button.dataset.confirm)) { + return; + } + const result = document.querySelector("#process-result"); + const originalText = button.textContent; + button.disabled = true; + button.textContent = "处理中..."; + if (result) result.textContent = "正在执行,请稍等..."; + + try { + let endpoint = button.dataset.endpoint; + let data; + try { + data = await apiFetch(endpoint, { method: "POST" }); + } catch (error) { + const needsAIConfirmation = error.status === 409 + && error.details?.code === "ai_retry_confirmation_required"; + if (!needsAIConfirmation) throw error; + const confirmed = window.confirm( + `${error.details.message}\n\n这一步可能产生新的 AI 调用费用,是否确认继续?`, + ); + if (!confirmed) { + if (result) result.textContent = "已取消 AI 重试,原失败 Job 和证据保持不变。"; + return; + } + const separator = endpoint.includes("?") ? "&" : "?"; + endpoint = `${endpoint}${separator}confirm_uncertain_ai=true`; + if (result) result.textContent = "已确认,正在安全创建或恢复 AI 分析 Job..."; + data = await apiFetch(endpoint, { method: "POST" }); + } + if (result) result.textContent = data.message || "处理完成,正在刷新页面..."; + if (button.dataset.endpoint.includes("/process/transcript")) { + startTranscriptPolling(true); + startTaskLiveStatusPolling(true); + if (data.status === "completed") { + window.setTimeout(() => window.location.reload(), 600); + } + return; + } + if (button.dataset.endpoint.includes("/process/auto-")) { + if (result) result.textContent = data.message || "全自动流程已启动,状态会在当前页面自动更新。"; + startTaskLiveStatusPolling(true); + return; + } + window.location.reload(); + } catch (error) { + if (result) result.textContent = `处理失败:${error.message}`; + } finally { + button.disabled = false; + button.textContent = originalText; + } +} + +function bindProcessActionButton(button) { + if (!button || button.dataset.processActionBound === "true") return; + button.dataset.processActionBound = "true"; + button.addEventListener("click", () => handleProcessAction(button)); +} + +document.querySelectorAll(".js-process-action").forEach((button) => { + bindProcessActionButton(button); +}); + +const transcriptPanel = document.querySelector("#transcript-panel"); +const transcriptProgress = document.querySelector("#transcript-progress"); +const transcriptProgressMessage = document.querySelector("#transcript-progress-message"); +const transcriptProgressPercent = document.querySelector("#transcript-progress-percent"); +const transcriptProgressBar = document.querySelector("#transcript-progress-bar"); +const transcriptProgressDetail = document.querySelector("#transcript-progress-detail"); +const transcriptProgressRuntime = document.querySelector("#transcript-progress-runtime"); +const transcriptPreviewBox = document.querySelector("#transcript-preview-box"); +const cancelTranscriptButtons = document.querySelectorAll(".js-cancel-transcript"); +const localTranscriptButton = document.querySelector("#local-transcript-button"); +let transcriptPollingTimer = null; +let transcriptPollingStartedFromRunning = false; + +function renderTranscriptPreview(preview, transcriptExists) { + if (!transcriptPreviewBox) return; + transcriptPreviewBox.replaceChildren(); + if (preview.length) { + preview.forEach((line) => { + const paragraph = document.createElement("p"); + const time = document.createElement("time"); + time.textContent = line.time; + paragraph.append(time, document.createTextNode(line.text)); + transcriptPreviewBox.append(paragraph); + }); + return; + } + + const empty = document.createElement("p"); + empty.className = "empty-note"; + empty.textContent = transcriptExists + ? "已生成转写文件,但当前文件里还没有真实语音转写内容。请查看上方进度或日志。" + : "尚未生成转写文件。请先点击“提取音频”,再点击“生成转写 MD”。"; + transcriptPreviewBox.append(empty); +} + +function renderTranscriptStatus(data) { + if (!transcriptProgress || !data.progress || !Object.keys(data.progress).length) return; + const progress = data.progress; + const percent = Number(progress.percent || 0); + transcriptProgress.hidden = false; + transcriptProgress.dataset.status = progress.status || "running"; + transcriptProgressMessage.textContent = progress.message || "转写进度"; + transcriptProgressPercent.textContent = `${percent}%`; + transcriptProgressBar.style.width = `${percent}%`; + + if (progress.total_chunks) { + transcriptProgressDetail.textContent = `转写进度:${progress.current_chunk || 0}/${progress.total_chunks},约 ${percent}%`; + } else { + transcriptProgressDetail.textContent = "转写进度:正在准备分段"; + } + + const runtimeParts = [progress.provider_label, progress.model, progress.device, progress.compute_type].filter(Boolean); + transcriptProgressRuntime.textContent = runtimeParts.length + ? `当前转写配置:${runtimeParts.join(" / ")}` + : ""; + + updateCancelTranscriptButtons(progress.status || data.task_status); + renderTranscriptPreview(data.preview || [], data.transcript_exists); + updateWorkflowButtons(data); +} + +let transcriptRequestInFlight = false; +async function pollTranscriptStatus() { + if (!transcriptPanel || document.hidden || transcriptRequestInFlight) return; + if (transcriptPollingTimer) window.clearTimeout(transcriptPollingTimer); + transcriptPollingTimer = null; + transcriptRequestInFlight = true; + let retry = false; + try { + const data = await apiFetch(`/api/tasks/${transcriptPanel.dataset.taskId}/transcript-status`); + renderTranscriptStatus(data); + retry = ["running", "cancelling"].includes(data.progress?.status); + transcriptPollingStartedFromRunning = retry; + } catch (error) { + retry = true; + if (transcriptProgressMessage) transcriptProgressMessage.textContent = `状态读取中断,将自动重试:${error.message}`; + } finally { + transcriptRequestInFlight = false; + if (retry && !document.hidden) transcriptPollingTimer = window.setTimeout(pollTranscriptStatus, 5000); + } +} + +function startTranscriptPolling(runImmediately = false) { + if (!transcriptPanel) return; + if (transcriptPollingTimer) { + window.clearTimeout(transcriptPollingTimer); + transcriptPollingTimer = null; + } + transcriptPollingStartedFromRunning = true; + if (runImmediately) { + pollTranscriptStatus().catch(() => {}); + return; + } + transcriptPollingTimer = window.setTimeout(pollTranscriptStatus, 5000); +} + +if (transcriptPanel) { + pollTranscriptStatus().catch(() => {}); +} + +function updateWorkflowButtons(data) { + const startButton = document.querySelector("#start-workflow-button"); + if (!startButton) return; + const progressStatus = data.progress?.status || ""; + const canRetryWithLocal = Boolean(data.local_retry_available); + const retryLabel = data.offline_only ? "重新本地转写" : "重新远程转写"; + if (localTranscriptButton) { + localTranscriptButton.hidden = !canRetryWithLocal; + } + if (data.transcript_exists) { + startButton.textContent = "转写已完成"; + startButton.disabled = true; + if (localTranscriptButton) localTranscriptButton.hidden = true; + return; + } + if (progressStatus === "running" || data.task_status === "transcribing") { + startButton.textContent = "转写处理中"; + startButton.disabled = true; + if (localTranscriptButton) localTranscriptButton.hidden = true; + updateCancelTranscriptButtons(progressStatus || "running"); + return; + } + if (progressStatus === "failed") { + startButton.textContent = retryLabel; + startButton.disabled = false; + startButton.classList.add("js-process-action"); + startButton.dataset.endpoint = `/api/tasks/${transcriptPanel?.dataset.taskId}/process/transcript-workflow?force=true`; + bindProcessActionButton(startButton); + updateCancelTranscriptButtons("failed"); + return; + } + if (progressStatus === "cancelled" || progressStatus === "stale") { + startButton.textContent = progressStatus === "stale" ? retryLabel : "重新生成转写"; + startButton.disabled = false; + startButton.classList.add("js-process-action"); + startButton.dataset.endpoint = `/api/tasks/${transcriptPanel?.dataset.taskId}/process/transcript-workflow?force=true`; + bindProcessActionButton(startButton); + updateCancelTranscriptButtons(progressStatus); + } +} + +function updateCancelTranscriptButtons(status) { + const shouldShow = status === "running" || status === "cancelling" || status === "transcribing"; + cancelTranscriptButtons.forEach((button) => { + button.hidden = !shouldShow; + button.disabled = status === "cancelling"; + button.textContent = status === "cancelling" ? "正在停止..." : "停止转写"; + }); +} + +cancelTranscriptButtons.forEach((button) => { + button.addEventListener("click", async () => { + if (!window.confirm("停止后不会删除已生成的旧转写文件。确认停止当前转写吗?")) { + return; + } + const result = document.querySelector("#process-result"); + const taskId = button.dataset.taskId || transcriptPanel?.dataset.taskId; + cancelTranscriptButtons.forEach((item) => { + item.disabled = true; + item.textContent = "正在停止..."; + }); + if (result) result.textContent = "正在请求停止转写..."; + + try { + const response = await fetch(`/api/tasks/${taskId}/process/transcript-cancel`, { method: "POST" }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "停止转写失败"); + } + if (result) result.textContent = data.message || "已请求停止转写。"; + startTranscriptPolling(true); + } catch (error) { + if (result) result.textContent = `停止转写失败:${error.message}`; + updateCancelTranscriptButtons("running"); + } + }); +}); + +const aiAnalysisForm = document.querySelector("#ai-analysis-form"); +const aiAnalysisProvider = document.querySelector("#ai-analysis-provider"); +const saveAiPromptsButton = document.querySelector("#save-ai-prompts-button"); +const aiProcessResult = document.querySelector("#ai-process-result"); +const aiAnalysisSummary = document.querySelector("#ai-analysis-summary"); +const aiCandidateCountPill = document.querySelector("#ai-candidate-count-pill"); +const aiCandidateCountInput = document.querySelector("#ai-candidate-count-input"); +const aiSelectionProfile = aiAnalysisForm + ? aiAnalysisForm.dataset.selectionProfile || "general" + : "general"; +const aiFinalClipTarget = document.querySelector("#ai-final-clip-target"); +const aiHighlightDensity = document.querySelector("#ai-highlight-density"); +const aiHighlightTotalLimit = document.querySelector("#ai-highlight-total-limit"); +const showAiHistoryButton = document.querySelector("#show-ai-history-button"); +const refreshAiHistoryButton = document.querySelector("#refresh-ai-history-button"); +const aiAnalysisHistory = document.querySelector("#ai-analysis-history"); +const aiAnalysisHistoryList = document.querySelector("#ai-analysis-history-list"); +const aiAnalysisProgress = document.querySelector("#ai-analysis-progress"); +const aiAnalysisProgressMessage = document.querySelector("#ai-analysis-progress-message"); +const aiAnalysisProgressPercent = document.querySelector("#ai-analysis-progress-percent"); +const aiAnalysisProgressBar = document.querySelector("#ai-analysis-progress-bar"); +const runtimeLogState = document.querySelector("#runtime-log-state"); +const runtimeLogLines = document.querySelector("#runtime-log-lines"); +const aiProcessButtons = Array.from(document.querySelectorAll(".js-ai-process-action")); +const aiAnalysisControls = Array.from(new Set([ + ...(aiAnalysisForm ? aiAnalysisForm.querySelectorAll("button, input, textarea, select") : []), + ...aiProcessButtons, + saveAiPromptsButton, + aiCandidateCountInput, + aiFinalClipTarget, +].filter(Boolean))); +const autoPipelineMonitor = document.querySelector("[data-auto-pipeline-monitor]"); +const taskLiveOverview = document.querySelector("[data-task-live-overview]"); +const taskLiveProgressBar = document.querySelector("[data-task-live-progress-bar]"); +const taskLiveProgressNumber = document.querySelector("[data-task-live-progress-number]"); +const taskLiveNote = document.querySelector("[data-task-live-note]"); +const taskLiveUpdatedAt = document.querySelector("[data-task-live-updated-at]"); +const taskLiveCandidateCount = document.querySelector("[data-task-live-candidate-count]"); +const taskLiveOutputCount = document.querySelector("[data-task-live-output-count]"); +const taskLiveActions = document.querySelector("[data-live-task-actions]"); +const taskLiveOperation = document.querySelector("[data-task-live-operation]"); +const taskLiveOperationLabel = document.querySelector("[data-task-live-operation-label]"); +const taskLiveOperationProgress = document.querySelector("[data-task-live-operation-progress]"); +const taskLiveOperationMessage = document.querySelector("[data-task-live-operation-message]"); +let aiStatusPollingTimer = null; +let isAiAnalysisBusy = false; +let aiAnalysisControlStates = new Map(); +let taskLiveStatusTimer = null; +let taskLiveForcedPollingUntil = 0; +let taskLiveStatusRequestInFlight = false; +const TASK_LIVE_STATUS_INTERVAL_MS = 3000; + +function setAiAnalysisControlsDisabled(disabled) { + if (disabled) { + aiAnalysisControlStates = new Map( + aiAnalysisControls.map((control) => [control, control.disabled]), + ); + aiAnalysisControls.forEach((control) => { control.disabled = true; }); + return; + } + aiAnalysisControls.forEach((control) => { + control.disabled = aiAnalysisControlStates.get(control) ?? false; + }); + aiAnalysisControlStates.clear(); +} + +function summarizeErrorMessage(message, maxLength = 220) { + const text = String(message || "").replace(/\s+/g, " ").trim(); + if (!text) return "操作失败,请查看任务日志。"; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}...(详细原因请查看任务日志)`; +} +let aiAnalysisRuns = []; + +function readJsonScript(id, fallback) { + const node = document.querySelector(`#${id}`); + if (!node?.textContent?.trim()) return fallback; + try { + return JSON.parse(node.textContent); + } catch { + return fallback; + } +} + +function getSelectedPromptPresetCard() { + if (!aiAnalysisForm) return null; + const selected = aiAnalysisForm.querySelector("input[name='ai_prompt_preset_id']:checked"); + if (!selected) return null; + return aiAnalysisForm.querySelector(`[data-prompt-preset-card][data-preset-id='${selected.value}']`); +} + +function updatePromptPresetCards() { + const selected = aiAnalysisForm?.querySelector("input[name='ai_prompt_preset_id']:checked"); + const selectedPresetId = selected?.value || ""; + document.querySelectorAll("[data-prompt-preset-tab]").forEach((tab) => { + const radio = tab.querySelector("input[name='ai_prompt_preset_id']"); + const isActive = radio?.value === selectedPresetId; + tab.classList.toggle("active", isActive); + tab.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + document.querySelectorAll("[data-prompt-preset-card]").forEach((card) => { + const isActive = card.dataset.presetId === selectedPresetId; + card.classList.toggle("active", isActive); + card.hidden = !isActive; + }); +} + +function formatClipDuration(seconds) { + const totalSeconds = Number(seconds) || 0; + const minutes = Math.floor(totalSeconds / 60); + const restSeconds = totalSeconds % 60; + if (minutes <= 0) return `${restSeconds} 秒`; + return `${minutes} 分 ${String(restSeconds).padStart(2, "0")} 秒`; +} + +function analysisPromptSource(data) { + const slot = /^preset_0*(\d+)$/.exec(data.ai_prompt_preset_id || "")?.[1]; + const name = data.ai_prompt_preset_name || "来源未记录"; + const revision = data.prompt_version_number ? `第 ${data.prompt_version_number} 次` : "未记录"; + return `提示词方案:${slot ? `${slot} 号 · ` : ""}${name} · 内容修订:${revision}`; +} + +function renderAiAnalysisSummary(data) { + if (!aiAnalysisSummary) return; + const clips = Array.isArray(data.clips) && data.clips.length ? data.clips : (data.clip_summaries || []); + aiAnalysisSummary.hidden = false; + aiAnalysisSummary.replaceChildren(); + + const header = document.createElement("div"); + header.className = "ai-analysis-result-header"; + const titleWrap = document.createElement("div"); + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = "Analysis Result"; + const title = document.createElement("h3"); + title.textContent = data.run_number ? `${data.title || `第 ${data.run_number} 次分析`} · AI 选取结果` : "本次 AI 选取结果"; + const promptSource = document.createElement("p"); + promptSource.className = "form-hint"; + promptSource.textContent = analysisPromptSource(data); + titleWrap.append(eyebrow, title, promptSource); + const source = document.createElement("span"); + source.className = "status-pill"; + source.textContent = data.provider_label && data.model ? `${data.provider_label} · 模型 ${data.model}` : "AI 分析完成"; + header.append(titleWrap, source); + + const message = document.createElement("p"); + message.className = "ai-analysis-result-message"; + message.textContent = data.failure_message || data.fallback_notice || data.analysis_summary || data.message || "AI 分析已完成。"; + + const meta = document.createElement("div"); + meta.className = "ai-analysis-result-meta"; + const count = document.createElement("strong"); + count.textContent = `${clips.length} 条候选片段`; + const provider = document.createElement("span"); + provider.textContent = data.fallback_notice ? "远程失败后已自动改用本地 AI" : `目标 ${data.requested_clip_count || clips.length || 0} 条`; + meta.append(count, provider); + + const list = document.createElement("div"); + list.className = "ai-analysis-result-list"; + clips.forEach((clip, index) => { + const item = document.createElement("article"); + item.className = "ai-analysis-result-item"; + const itemTitle = document.createElement("strong"); + itemTitle.textContent = `${String(index + 1).padStart(2, "0")} · ${clip.title || "未命名片段"}`; + const itemMeta = document.createElement("span"); + itemMeta.textContent = `视频长度 ${formatClipDuration(clip.duration_seconds)} · ${clip.start_time || "--"} - ${clip.end_time || "--"}`; + item.append(itemTitle, itemMeta); + list.append(item); + }); + + const actions = document.createElement("div"); + actions.className = "button-row align-end"; + const reviewLink = document.createElement("a"); + reviewLink.className = "primary-button"; + reviewLink.href = data.review_url || `/tasks/${aiAnalysisForm?.dataset.taskId || ""}/clips/review`; + reviewLink.textContent = data.analysis_incomplete ? "查看已保留的候选(待补齐分析)" : "去检查并生成切片"; + actions.append(reviewLink); + + aiAnalysisSummary.append(header, message, meta, list, actions); +} + +function renderAiAnalysisHistory(runs) { + if (!aiAnalysisHistoryList) return; + aiAnalysisRuns = Array.isArray(runs) ? runs : []; + aiAnalysisHistoryList.replaceChildren(); + + if (!aiAnalysisRuns.length) { + const empty = document.createElement("p"); + empty.className = "empty-note"; + empty.textContent = "还没有历史分析结果。完成一次 AI 分析后,这里会自动出现记录。"; + aiAnalysisHistoryList.append(empty); + return; + } + + aiAnalysisRuns.forEach((run) => { + const item = document.createElement("article"); + item.className = "ai-history-item"; + const main = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = `${run.title || `第 ${run.run_number} 次分析`} · ${run.clip_count || 0} 条`; + const meta = document.createElement("span"); + meta.textContent = `${run.provider_label || "AI"} · ${run.model || "未知模型"} · ${analysisPromptSource(run)} · ${run.created_at || "未知时间"}`; + const summary = document.createElement("p"); + summary.textContent = run.failure_message || run.fallback_notice || run.analysis_summary || "暂无整体总结。"; + main.append(title, meta, summary); + if (run.challenger?.id) { + const trialLink = document.createElement("a"); + trialLink.href = `/api/content-review/challengers/${encodeURIComponent(run.challenger.id)}`; + trialLink.textContent = "Challenger 试验 · 查看来源版本"; + main.append(trialLink); + } + const visual = run.visual_signal || run.analysis_meta?.visual_signal; + if (visual) { + const link = document.createElement("a"); + link.href = `/tasks/${encodeURIComponent(aiAnalysisForm.dataset.taskId)}/visual-evidence?run_id=${encodeURIComponent(run.id)}`; + const labels = { disabled: "关闭", completed: "完成", partial: "部分可用", unavailable: "不可用" }; + link.textContent = `视觉 ${labels[visual.status] || visual.status} · ${visual.verified_count || 0}/${visual.candidate_count || 0} 候选 · 查看证据`; + main.append(link); + } + + const restoreButton = document.createElement("button"); + restoreButton.className = "secondary-button compact-button"; + restoreButton.type = "button"; + restoreButton.dataset.restoreRunId = run.id; + restoreButton.textContent = "恢复这次结果"; + item.append(main, restoreButton); + aiAnalysisHistoryList.append(item); + }); +} + +async function refreshAiAnalysisHistory() { + if (!aiAnalysisForm) return; + const taskId = aiAnalysisForm.dataset.taskId; + const response = await fetch(`/api/tasks/${taskId}/ai-analysis-runs`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "读取历史分析失败"); + } + renderAiAnalysisHistory(data.runs || []); + if (data.latest) { + renderAiAnalysisSummary(data.latest); + } +} + +async function saveTaskCandidateClipCount() { + if (!aiAnalysisForm || !aiCandidateCountInput) return null; + const taskId = aiAnalysisForm.dataset.taskId; + const count = Number(aiCandidateCountInput.value || 12); + if (!Number.isInteger(count) || count < 1 || count > 50) { + throw new Error("候选片段数量必须是 1 到 50 之间的整数。"); + } + const response = await fetch(`/api/tasks/${taskId}/candidate-clip-count`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidate_clip_count: count }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "候选片段数量保存失败"); + } + return data; +} + +async function saveTaskAiPromptSettings() { + if (!aiAnalysisForm) return null; + if (aiAnalysisForm.dataset.challengerId) return {message: "Challenger 的 Profile 与 Prompt 已冻结;已保存其他分析设置。"}; + const taskId = aiAnalysisForm.dataset.taskId; + const selected = aiAnalysisForm.querySelector("input[name='ai_prompt_preset_id']:checked"); + if (!selected) { + throw new Error("请选择一个 AI Prompt 方案"); + } + + const response = await fetch(`/api/tasks/${taskId}/ai-prompt-preset`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ai_prompt_preset_id: selected.value, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "AI Prompt 方案选择保存失败"); + } + return data; +} + +if (aiAnalysisForm) { + aiAnalysisForm.querySelectorAll("input[name='ai_prompt_preset_id']").forEach((radio) => { + radio.addEventListener("change", updatePromptPresetCards); + }); + aiAnalysisForm.querySelectorAll("[data-prompt-preset-card] input[type='text']").forEach((input) => { + input.addEventListener("input", () => { + if (aiProcessResult) aiProcessResult.textContent = "Prompt 方案内容已修改,分析前会自动保存。"; + }); + }); + updatePromptPresetCards(); +} + +function taskSettingsSignature() { + return aiAnalysisForm ? JSON.stringify(Array.from(aiAnalysisForm.elements).filter(node => node.name).map(node => [node.name, node.type === "checkbox" || node.type === "radio" ? node.checked : node.value])) : ""; +} +let savedTaskSettings = taskSettingsSignature(); +function markTaskSettingsSaved() { savedTaskSettings = taskSettingsSignature(); } +aiAnalysisForm?.addEventListener("input", () => { if (aiProcessResult) aiProcessResult.textContent = "有未保存的分析设置"; }); +window.addEventListener("beforeunload", event => { + if (taskSettingsSignature() === savedTaskSettings) return; + event.preventDefault(); event.returnValue = ""; +}); + +if (saveAiPromptsButton && aiAnalysisForm) { + saveAiPromptsButton.addEventListener("click", async () => { + if (isAiAnalysisBusy) return; + const originalText = saveAiPromptsButton.textContent; + saveAiPromptsButton.disabled = true; + saveAiPromptsButton.textContent = "保存中..."; + if (aiProcessResult) aiProcessResult.textContent = "正在保存 AI Prompt 方案..."; + + try { + const data = await saveTaskAiPromptSettings(); + await saveTaskCandidateClipCount(); + await saveTaskSelectionSettings(); + markTaskSettingsSaved(); + if (aiProcessResult) aiProcessResult.textContent = data.message || "AI Prompt 方案已保存。"; + } catch (error) { + if (aiProcessResult) aiProcessResult.textContent = `保存失败:${error.message}`; + } finally { + saveAiPromptsButton.disabled = isAiAnalysisBusy; + saveAiPromptsButton.textContent = originalText; + } + }); +} + +aiProcessButtons.forEach((button) => { + button.addEventListener("click", async () => { + if (!aiAnalysisForm || isAiAnalysisBusy) return; + const originalText = button.textContent; + const taskId = aiAnalysisForm.dataset.taskId; + const provider = aiAnalysisProvider?.value || ""; + const selectedCard = getSelectedPromptPresetCard(); + const selectedPrompt = selectedCard?.querySelector("textarea")?.value.trim() || ""; + const selectedName = selectedCard?.querySelector("input[type='text']")?.value.trim() || "当前方案"; + if (!selectedPrompt) { + if (aiProcessResult) aiProcessResult.textContent = "请先填写当前选中的 AI Prompt 方案。"; + return; + } + if (!provider) { + const confirmed = window.confirm(`确认使用“${selectedName}”并沿用任务设置开始分析吗?\n\n历史任务未记录分析方式时使用当前默认设置;失败任务恢复仍沿用原记录。将消耗对应模型额度,并覆盖现有 AI 候选结果。`); + if (!confirmed) return; + } else if (provider === "codex") { + const confirmed = window.confirm(`确认使用“${selectedName}”发起 Codex CLI 分析吗?\n\n这会消耗当前 Codex 套餐额度,并覆盖现有 AI 候选结果。`); + if (!confirmed) return; + } else if (provider === "remote") { + const confirmed = window.confirm(`确认使用“${selectedName}”发起远程 AI 分析吗?\n\n这会重新生成候选片段,并覆盖当前已有的 AI 候选结果。`); + if (!confirmed) return; + } + isAiAnalysisBusy = true; + setAiAnalysisControlsDisabled(true); + button.textContent = "分析中..."; + if (aiProcessResult) aiProcessResult.textContent = "正在保存 Prompt 方案并启动 AI 分析..."; + renderAiAnalysisProgress({ + status: "running", + percent: 18, + message: "正在保存 Prompt 方案并启动 AI 分析...", + }); + startTaskLiveStatusPolling(true); + + try { + await saveTaskAiPromptSettings(); + await saveTaskCandidateClipCount(); + await saveTaskSelectionSettings(); + markTaskSettingsSaved(); + pollAiAnalysisStatus(true).catch(() => {}); + const response = await fetch(`/api/tasks/${taskId}/process/ai${provider ? `?provider=${provider}` : ""}`, { + method: "POST", + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "AI 分析失败"); + } + if (!data.job_id) throw new Error("AI 分析队列没有返回 job_id"); + if (aiProcessResult) aiProcessResult.textContent = data.message || "AI 分析已加入队列。"; + const completedJob = await waitForAiAnalysisJob(data.job_id); + const result = completedJob.result_json || {}; + if (aiProcessResult) aiProcessResult.textContent = result.message || completedJob.message || "AI 分析完成。"; + await pollAiAnalysisStatus(false).catch(() => {}); + if (aiCandidateCountPill && Number.isFinite(Number(result.clip_count))) { + aiCandidateCountPill.textContent = `${Number(result.clip_count)} 条候选`; + } + const historyResponse = await fetch(`/api/tasks/${taskId}/ai-analysis-runs`); + const historyData = await historyResponse.json(); + if (!historyResponse.ok) throw new Error(historyData.detail || "读取 AI 分析历史失败"); + renderAiAnalysisSummary(historyData.latest || result); + renderAiAnalysisHistory(historyData.runs || aiAnalysisRuns); + } catch (error) { + if (aiProcessResult) aiProcessResult.textContent = `AI 分析失败:${summarizeErrorMessage(error.message)}`; + await pollAiAnalysisStatus(false).catch(() => {}); + } finally { + stopAiAnalysisStatusPolling(); + isAiAnalysisBusy = false; + setAiAnalysisControlsDisabled(false); + button.textContent = originalText; + } + }); +}); + +if (aiAnalysisForm) { + const latestAiAnalysis = readJsonScript("latest-ai-analysis-data", null); + const initialAiAnalysisRuns = readJsonScript("ai-analysis-runs-data", []); + renderAiAnalysisHistory(initialAiAnalysisRuns); + if (latestAiAnalysis) { + renderAiAnalysisSummary(latestAiAnalysis); + } + pollAiAnalysisStatus(false).catch(() => {}); +} + +if (showAiHistoryButton && aiAnalysisHistory) { + showAiHistoryButton.addEventListener("click", () => { + aiAnalysisHistory.hidden = !aiAnalysisHistory.hidden; + }); +} + +if (refreshAiHistoryButton) { + refreshAiHistoryButton.addEventListener("click", async () => { + const originalText = refreshAiHistoryButton.textContent; + refreshAiHistoryButton.disabled = true; + refreshAiHistoryButton.textContent = "刷新中..."; + try { + await refreshAiAnalysisHistory(); + if (aiProcessResult) aiProcessResult.textContent = "历史分析结果已刷新。"; + } catch (error) { + if (aiProcessResult) aiProcessResult.textContent = `刷新历史失败:${error.message}`; + } finally { + refreshAiHistoryButton.disabled = false; + refreshAiHistoryButton.textContent = originalText; + } + }); +} + +if (aiAnalysisHistoryList) { + aiAnalysisHistoryList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-restore-run-id]"); + if (!button || !aiAnalysisForm) return; + const runId = button.dataset.restoreRunId; + const confirmed = window.confirm("确认恢复这次 AI 分析结果吗?\n\n当前片段审核页的候选片段会被这次历史结果覆盖。"); + if (!confirmed) return; + + const taskId = aiAnalysisForm.dataset.taskId; + const originalText = button.textContent; + button.disabled = true; + button.textContent = "恢复中..."; + try { + const response = await fetch(`/api/tasks/${taskId}/ai-analysis-runs/${runId}/restore`, { + method: "POST", + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || "恢复失败"); + } + if (aiProcessResult) aiProcessResult.textContent = data.message || "历史结果已恢复。"; + if (aiCandidateCountPill && Array.isArray(data.clips)) { + aiCandidateCountPill.textContent = `${data.clips.length} 条候选`; + } + renderAiAnalysisSummary(data.restored_run || data.latest); + renderAiAnalysisHistory(data.runs || aiAnalysisRuns); + } catch (error) { + if (aiProcessResult) aiProcessResult.textContent = `恢复失败:${error.message}`; + } finally { + button.disabled = false; + button.textContent = originalText; + } + }); +} + +async function saveTaskSelectionSettings() { + if (!aiAnalysisForm || !aiSelectionProfile || !aiFinalClipTarget) return null; + const finalTarget = Number(aiFinalClipTarget.value || 5); + if (!Number.isInteger(finalTarget) || finalTarget < 1 || finalTarget > 12) { + throw new Error("最终启用目标必须是 1 到 12 之间的整数。"); + } + const settingsPayload = { + selection_profile: aiSelectionProfile, + final_clip_target: finalTarget, + }; + if (aiSelectionProfile === "long_live_talk") { + settingsPayload.highlight_density_per_hour = Number(aiHighlightDensity?.value || 4); + settingsPayload.highlight_total_limit = Number(aiHighlightTotalLimit?.value || 30); + } + const response = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/selection-settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settingsPayload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "选片设置保存失败"); + const visual = document.querySelector("#task-visual-enabled"); + if (visual) { + const visualResponse = await fetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/visual-settings`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ visual_enabled: visual.checked }), + }); + const visualData = await visualResponse.json(); + if (!visualResponse.ok) throw new Error(visualData.detail || "视觉设置保存失败"); + } + return data; +} + +function renderAiAnalysisProgress(status) { + if (!aiAnalysisProgress) return; + const percent = Math.max(0, Math.min(100, Number(status.percent || 0))); + aiAnalysisProgress.hidden = false; + aiAnalysisProgress.dataset.status = status.status || "idle"; + if (aiAnalysisProgressMessage) { + aiAnalysisProgressMessage.textContent = status.message || "AI 分析进度"; + } + if (aiAnalysisProgressPercent) { + aiAnalysisProgressPercent.textContent = `${percent}%`; + } + if (aiAnalysisProgressBar) { + aiAnalysisProgressBar.style.width = `${percent}%`; + } +} + +function renderRuntimeLog(status) { + if (runtimeLogState) { + const labelMap = { + idle: "待开始", + running: "分析中", + completed: "已完成", + failed: "失败", + }; + const runtimeStatus = status.runtime_status || status.status || "idle"; + runtimeLogState.textContent = status.runtime_status_label + || labelMap[runtimeStatus] + || status.status_label + || status.task_status_label + || "已刷新"; + runtimeLogState.dataset.status = runtimeStatus; + } + if (!runtimeLogLines) return; + const lines = Array.isArray(status.log_lines) ? status.log_lines : []; + runtimeLogLines.textContent = lines.length ? lines.join("\n") : "暂无运行日志。任务开始后,这里会自动刷新。"; + runtimeLogLines.scrollTop = runtimeLogLines.scrollHeight; +} + +function formatTaskLiveRefreshTime() { + return new Date().toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function renderTaskLiveActions(data) { + if (!taskLiveActions) return; + const actions = data.actions || {}; + let primaryAction = actions.primary || "none"; + if (primaryAction === "publish" && !actions.publish) { + primaryAction = "none"; + } + + taskLiveActions.querySelectorAll("[data-live-primary-action]").forEach((node) => { + node.hidden = node.dataset.livePrimaryAction !== primaryAction; + }); + + const reviewAction = taskLiveActions.querySelector("[data-live-review-action]"); + if (reviewAction) reviewAction.hidden = !actions.review || primaryAction === "review_outputs"; + const syncAction = taskLiveActions.querySelector("[data-live-sync-action]"); + if (syncAction) { + syncAction.hidden = ["subtitle_review", "review_outputs"].includes(primaryAction) || Number(data.counts?.outputs || 0) <= 0; + } + const subtitleSkip = taskLiveActions.querySelector("[data-live-subtitle-skip]"); + if (subtitleSkip) subtitleSkip.hidden = primaryAction !== "subtitle_review" || actions.subtitle_skip === false; +} + +async function waitForAiAnalysisJob(jobId) { + while (true) { + const response = await fetch(`/api/tasks/jobs/${jobId}`); + const job = await response.json(); + if (!response.ok) throw new Error(job.detail || "查询 AI 分析任务进度失败"); + renderAiAnalysisProgress({ + status: job.status, + percent: Number(job.progress || 0), + message: job.status === "failed" + ? (job.error_message || job.message || "AI 分析失败") + : (job.message || "AI 分析正在排队..."), + }); + if (job.status === "completed") return job; + if (job.status === "failed" || job.status === "cancelled") { + throw new Error(job.error_message || job.message || "AI 分析任务未完成"); + } + await wait(1000); + } +} + +function renderTaskLiveStatus(data) { + const next = document.querySelector("[data-ui-task-primary-action]"); + if (next && data.ui?.primary_action) { + const target = new URL(data.ui.primary_action.url, window.location.origin); + if (next.dataset.returnTo) target.searchParams.set("return_to", next.dataset.returnTo); + next.href = target.pathname + target.search + target.hash; + next.textContent = data.ui.primary_action.label; + } + const message = document.querySelector("[data-ui-task-message]"); + if (message && data.ui) message.textContent = data.ui.message; + const progress = Math.max(0, Math.min(100, Number(data.ui?.production_progress ?? data.progress ?? 0))); + document.querySelectorAll("[data-task-live-status-label]").forEach((node) => { + node.textContent = data.ui?.stage_label || data.status_label || data.status || "状态未知"; + node.dataset.status = data.runtime_status || (data.should_poll ? "running" : "completed"); + }); + const headerStatus = document.querySelector("[data-task-live-header-status]"); + if (headerStatus) headerStatus.textContent = data.ui?.stage_label || data.status_label || data.status || "状态未知"; + if (taskLiveProgressBar) taskLiveProgressBar.style.width = `${progress}%`; + if (taskLiveProgressNumber) taskLiveProgressNumber.textContent = `${progress}%`; + if (taskLiveUpdatedAt) taskLiveUpdatedAt.textContent = data.updated_at || "未知"; + + const candidateCount = Number(data.counts?.candidates || 0); + const outputCount = Number(data.counts?.outputs || 0); + if (taskLiveCandidateCount) taskLiveCandidateCount.textContent = `${candidateCount} 条`; + if (taskLiveOutputCount) taskLiveOutputCount.textContent = `${outputCount} 条`; + if (aiCandidateCountPill) aiCandidateCountPill.textContent = `${candidateCount} 条候选`; + + const rawOperation = data.active_operation || {}; + const operation = data.ui && (!rawOperation.kind || ["task", "idle"].includes(rawOperation.kind)) + ? {...rawOperation, progress: data.ui.production_progress, label: data.ui.stage_label, message: data.ui.message} + : rawOperation; + const operationProgress = Math.max(0, Math.min(100, Number(operation.progress || 0))); + if (taskLiveOperation) { + taskLiveOperation.dataset.status = operation.status || "idle"; + const scope = taskLiveOperation.querySelector("div > span"); + if (scope) scope.textContent = operation.kind === "publish" ? "发布状态(独立记录)" : "当前制作操作"; + } + if (taskLiveOperationLabel) { + taskLiveOperationLabel.textContent = operation.label || data.task_status_label || "当前任务"; + } + if (taskLiveOperationProgress) { + taskLiveOperationProgress.hidden = operation.kind === "publish"; + taskLiveOperationProgress.textContent = `${operationProgress}%`; + } + if (taskLiveOperationMessage) { + taskLiveOperationMessage.textContent = operation.message + || `当前阶段:${data.task_status_label || data.status_label || "状态未知"}`; + } + + const allowedStepStates = new Set(["done", "current", "pending", "warning"]); + (data.ui?.workflow_steps || (Array.isArray(data.workflow_steps) ? data.workflow_steps : [])).forEach((step) => { + const node = taskLiveOverview?.querySelector(`[data-task-live-step="${step.index}"]`); + if (!node) return; + node.classList.remove("done", "current", "pending", "warning"); + node.classList.add(allowedStepStates.has(step.state) ? step.state : "pending"); + const number = node.querySelector("span"); + const label = node.querySelector("strong"); + if (number) number.textContent = step.index; + if (label) label.textContent = step.name; + }); + + renderRuntimeLog(data); + renderTaskLiveActions(data); + if (autoPipelineMonitor) { + autoPipelineMonitor.dataset.status = data.status || ""; + autoPipelineMonitor.dataset.running = data.should_poll ? "true" : "false"; + } + if (taskLiveNote) { + taskLiveNote.dataset.snapshotAt = data.snapshot_at || ""; + if (data.error_message) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `流程已暂停:${summarizeErrorMessage(data.error_message)}`; + } else if (data.should_poll) { + taskLiveNote.dataset.state = "active"; + taskLiveNote.textContent = `自动刷新中 · ${formatTaskLiveRefreshTime()} 已获取最新状态`; + } else { + taskLiveNote.dataset.state = "completed"; + taskLiveNote.textContent = `状态已更新 · ${formatTaskLiveRefreshTime()}`; + } + } +} + +function scheduleTaskLiveStatusPolling() { + if (!autoPipelineMonitor || document.hidden) return; + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = window.setTimeout(() => { + pollTaskLiveStatus().catch(() => {}); + }, TASK_LIVE_STATUS_INTERVAL_MS); +} + +async function pollTaskLiveStatus() { + if (!autoPipelineMonitor) return null; + if (taskLiveStatusRequestInFlight) return null; + const taskId = autoPipelineMonitor.dataset.taskId; + if (!taskId) return null; + if (taskLiveStatusTimer) { + window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + } + + taskLiveStatusRequestInFlight = true; + try { + const data = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/live-status`); + renderTaskLiveStatus(data); + if (data.should_poll) taskLiveForcedPollingUntil = 0; + if (data.should_poll || Date.now() < taskLiveForcedPollingUntil) { + scheduleTaskLiveStatusPolling(); + } + return data; + } catch (error) { + if (taskLiveNote) { + taskLiveNote.dataset.state = "error"; + taskLiveNote.textContent = `自动更新暂时中断,正在重试:${summarizeErrorMessage(error.message)}`; + } + scheduleTaskLiveStatusPolling(); + return null; + } finally { + taskLiveStatusRequestInFlight = false; + } +} + +function startTaskLiveStatusPolling(forceRestart = false) { + if (!autoPipelineMonitor) return; + if (forceRestart) taskLiveForcedPollingUntil = Date.now() + 10000; + pollTaskLiveStatus().catch(() => {}); +} + +if (autoPipelineMonitor) { + startTaskLiveStatusPolling(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (taskLiveStatusTimer) window.clearTimeout(taskLiveStatusTimer); + taskLiveStatusTimer = null; + return; + } + startTaskLiveStatusPolling(); + }); +} + +let aiStatusRequestInFlight = false; +async function pollAiAnalysisStatus(keepPolling = false) { + if (!aiAnalysisForm || document.hidden || aiStatusRequestInFlight) return null; + if (aiStatusPollingTimer) window.clearTimeout(aiStatusPollingTimer); + aiStatusPollingTimer = null; + aiStatusRequestInFlight = true; + let retry = keepPolling; + try { + const data = await apiFetch(`/api/tasks/${aiAnalysisForm.dataset.taskId}/ai-analysis-status`); + renderAiAnalysisProgress(data); + retry = retry || data.is_running; + return data; + } catch (error) { + retry = true; + if (aiProcessResult) aiProcessResult.textContent = `状态读取中断,将自动重试:${error.message}`; + return null; + } finally { + aiStatusRequestInFlight = false; + if (retry && !document.hidden) aiStatusPollingTimer = window.setTimeout(() => pollAiAnalysisStatus(), 3000); + } +} + +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (transcriptPollingTimer) window.clearTimeout(transcriptPollingTimer); + if (aiStatusPollingTimer) window.clearTimeout(aiStatusPollingTimer); + } else { + pollTranscriptStatus(); + pollAiAnalysisStatus(); + } +}); + +function stopAiAnalysisStatusPolling() { + if (!aiStatusPollingTimer) return; + window.clearTimeout(aiStatusPollingTimer); + aiStatusPollingTimer = null; +} + + +document.querySelectorAll("[data-live-subtitle-skip]").forEach((button) => { + button.addEventListener("click", async () => { + if (!window.confirm("确认跳过字幕并进入片段审核吗?审核保存后才会同步发送中心。")) return; + const taskId = button.dataset.taskId; + button.disabled = true; + const originalText = button.textContent; + button.textContent = "正在进入审核..."; + try { + const data = await apiFetch(`/api/subtitles/tasks/${encodeURIComponent(taskId)}/skip-to-review`, { + method: "POST", + }); + if (taskLiveNote) taskLiveNote.textContent = data.message || "已跳过字幕,正在进入片段审核"; + window.location.href = data.review_url || `/tasks/${encodeURIComponent(taskId)}/clips/review`; + } catch (error) { + window.alert(`跳过字幕失败:${summarizeErrorMessage(error.message)}`); + button.disabled = false; + button.textContent = originalText; + } + }); +}); diff --git a/app/static/js/workspace-helpers.js b/app/static/js/workspace-helpers.js new file mode 100644 index 0000000..96275ce --- /dev/null +++ b/app/static/js/workspace-helpers.js @@ -0,0 +1,36 @@ +function wait(milliseconds) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} +function timeTextToSeconds(value) { + const parts = String(value || "") + .trim() + .split(":") + .map((part) => Number(part)); + if (![2, 3].includes(parts.length) || parts.some((part) => !Number.isFinite(part))) { + return 0; + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + return parts[0] * 3600 + parts[1] * 60 + parts[2]; +} + +function secondsToTimeText(seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const restSeconds = total % 60; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(restSeconds).padStart(2, "0")}`; +} + +function formatTimecode(seconds) { + return `${secondsToTimeText(seconds)}:00`; +} + +function showClipReviewMessage(message, tone = "info") { + const node = document.querySelector("#clip-review-message") || document.querySelector("#process-result"); + if (!node) return; + node.hidden = false; + node.textContent = message; + node.dataset.tone = tone; +} diff --git a/app/templates/base.html b/app/templates/base.html index a99a2f1..313fc2d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,63 +7,49 @@ - - + + + {% block extra_head %}{% endblock %} -
+ {% set nav_page = 'tasks' if active_page in ['new_task', 'clips', 'subtitles', 'task_detail', 'review_inbox'] else active_page %} + + 跳到页面内容