diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 3cc9170..5b560e2 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -236,6 +236,105 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id raise +def build_segment_rerun_prompt(*, project, segment, instruction: str = "") -> list[dict[str, str]]: + """单镜重跑提示词:带商品/卖点上下文 + 该镜当前内容 + 前后镜上下文(保连贯)+ 用户修改意见。 + 只重写这一镜,严格输出「旁白:…/画面:…」两行,供 parse_segment_fields 精确解析。""" + product = project.product + selling_points = product.selling_points.all() + selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points) + + script = segment.script_version + siblings = list(script.segments.order_by("sort_order")) + prev_seg = next((s for s in reversed(siblings) if s.sort_order < segment.sort_order), None) + next_seg = next((s for s in siblings if s.sort_order > segment.sort_order), None) + + def _brief(seg) -> str: + narration = (seg.narration or "").strip() + visual = (seg.visual_prompt or "").strip() + return f"旁白:{narration or '无'};画面:{visual or '无'}" + + system = ( + "你是电商短视频脚本导演。现在只需要**重写一条分镜**(其它分镜保持不变)。" + "结合商品卖点、该镜当前内容、前后镜上下文和用户的修改意见,重新生成这一镜的旁白口播与画面描述," + "并保证与前后镜衔接连贯。严格按以下格式输出两行,不要输出镜头编号或任何其它内容:\n" + "旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议" + ) + context_lines = [ + f"商品标题:{product.title}", + f"品牌:{product.brand or '未填写'}", + f"类目:{product.category or '未填写'}", + f"目标人群:{product.target_audience or '未填写'}", + f"卖点:\n{selling_text or '未选择卖点,请根据商品信息自行提炼。'}", + "", + f"这是第 {segment.sort_order + 1} 镜(共 {len(siblings)} 镜),时长约 {segment.duration_seconds} 秒。", + f"该镜当前内容:{_brief(segment)}", + ] + if prev_seg is not None: + context_lines.append(f"上一镜(保持不变,用于衔接):{_brief(prev_seg)}") + if next_seg is not None: + context_lines.append(f"下一镜(保持不变,用于衔接):{_brief(next_seg)}") + context_lines.append("") + context_lines.append(f"用户的修改意见:{instruction.strip() or '让这一镜更有吸引力、表达更清晰,并与前后镜自然衔接。'}") + user = "\n".join(context_lines).strip() + return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + +def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion: + """单镜 AI 重跑:只重新生成该 ScriptSegment 的 narration + visual_prompt(其它镜不动), + 沿用 generate_project_script 的 AITask + 计费(reserve/charge/release)闭环,同步调 LLM。 + 返回该 segment 所属的 ScriptVersion。""" + model_config = get_default_model(ModelConfig.Capability.TEXT) + if model_config is None: + raise ValueError("no active text model configured") + + messages = build_segment_rerun_prompt(project=project, segment=segment, instruction=instruction) + payload = { + "model": model_config.name, + "endpoint": model_config.endpoint, + "messages": messages, + "script_segment": str(segment.id), + } + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.SCRIPT_OPTIMIZATION, + model_config=model_config, + request_payload=payload, + ) + reservation = task.credit_reservation + + try: + task.status = AITask.Status.SUBMITTED + task.submitted_at = timezone.now() + task.save(update_fields=["status", "submitted_at", "updated_at"]) + + provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None) + response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) + content = provider.extract_text(response) + narration, visual = parse_segment_fields(content) + + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = response + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + + segment.narration = narration + segment.visual_prompt = visual + segment.save(update_fields=["narration", "visual_prompt", "updated_at"]) + return segment.script_version + except Exception as exc: + with transaction.atomic(): + task.status = AITask.Status.FAILED + task.error_message = str(exc) + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=reservation, reason=str(exc)) + raise + + def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None": """用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。""" if not video_bytes: diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 54ab811..ffeabe5 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -11,6 +11,7 @@ from apps.products.models import Product from apps.projects.models import ( Project, ProjectStage, + ScriptSegment, ScriptVersion, SubtitleTrack, Timeline, @@ -110,6 +111,47 @@ class ProjectApiTests(TestCase): self.assertEqual(script.segments.count(), 4) self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + @patch("apps.ai.services.VolcanoArkProvider") + def test_rerun_script_segment_updates_one_segment_and_charges_once(self, provider_cls): + provider = provider_cls.return_value + provider.chat_completion.return_value = {"choices": [{"message": {"content": "x"}}]} + provider.extract_text.return_value = "旁白:全新口播文案\n画面:全新画面描述" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + script = ScriptVersion.objects.create(project=project, title="脚本", content="...", is_adopted=True) + seg0 = ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") + seg1 = ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1") + + response = self.client.post( + f"/api/projects/{project.id}/rerun-script-segment/", + {"segment_id": str(seg1.id), "instruction": "更俏皮"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + seg0.refresh_from_db() + seg1.refresh_from_db() + self.assertEqual(seg1.narration, "全新口播文案") + self.assertEqual(seg1.visual_prompt, "全新画面描述") + # 其它分镜保持不动 + self.assertEqual(seg0.narration, "旧0") + self.assertEqual(seg0.visual_prompt, "画0") + # 返回的是该镜所属 ScriptVersion + self.assertEqual(str(response.data["id"]), str(script.id)) + self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + + def test_rerun_script_segment_rejects_foreign_segment(self): + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + other = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="O") + other_script = ScriptVersion.objects.create(project=other, title="脚本", content="...") + other_seg = ScriptSegment.objects.create(script_version=other_script, sort_order=0, narration="x") + + response = self.client.post( + f"/api/projects/{project.id}/rerun-script-segment/", + {"segment_id": str(other_seg.id)}, + format="json", + ) + self.assertEqual(response.status_code, 404) + def test_adopt_video_version_remaps_timeline_draft(self): """切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。""" project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 03dce3e..588de3b 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -18,6 +18,7 @@ from apps.ai.services import ( generate_project_script, generate_storyboard_frame, poll_video_segment, + regenerate_script_segment, submit_storyboard, submit_video_segment, synthesize_project_voiceover, @@ -216,6 +217,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): segment.save(update_fields=[*changed, "updated_at"]) return Response(ScriptVersionSerializer(segment.script_version).data) + @action(detail=True, methods=["post"], url_path="rerun-script-segment") + def rerun_script_segment(self, request, pk=None): + """单条分镜 AI 重跑:只重新生成该 ScriptSegment 的旁白+画面提示(其它镜不动),按现有计费扣费。""" + project = self.get_object() + segment_id = request.data.get("segment_id") + if not segment_id: + return Response({"detail": "segment_id is required"}, status=status.HTTP_400_BAD_REQUEST) + try: + segment = ScriptSegment.objects.get(id=segment_id, script_version__project=project) + except ScriptSegment.DoesNotExist: + return Response({"detail": "script segment not found"}, status=status.HTTP_404_NOT_FOUND) + try: + script = regenerate_script_segment( + project=project, + user=request.user, + segment=segment, + instruction=str(request.data.get("instruction") or "").strip(), + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response(ScriptVersionSerializer(script).data) + @action(detail=True, methods=["post"], url_path="add-script-segment") @transaction.atomic def add_script_segment(self, request, pk=None): diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 5b3683f..49e9f0f 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -345,8 +345,10 @@ export function App() { const result = await work(); // successText 为空 → 不弹 toast(交给调用方自定义反馈,如成功弹窗) if (successText) setNotice({ type: "success", text: successText }); - await loadData(); - await refreshProjectDetail(); + // 后台刷新,不阻塞操作返回:全量 loadData 会分页拉全部 assets 很重,await 它会让 + // 「项目已创建/确认脚本」后白等很久(行29/37)。改为后台 hydrate,操作立即返回。 + void loadData(); + void refreshProjectDetail(); return result; } catch (error) { setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" }); @@ -674,6 +676,11 @@ export function App() { onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")} onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")} onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")} + onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑")} + onSaveProjectMeta={(meta) => + // metadata 是整体替换:合并现有 project.metadata 后再 PATCH,别把别的 key(wizard 等)冲掉 + action(() => api.updateProject(pipelineProject.id, { metadata: { ...(pipelineProject.metadata ?? {}), ...meta } }), "已保存") + } onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")} onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")} onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8dcfb11..ec9a424 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -210,6 +210,10 @@ export const api = { createProject(payload: { name: string; product: string; metadata?: Record }) { return request("/api/projects/", { method: "POST", body: JSON.stringify(payload) }); }, + // 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉 + updateProject(id: string, payload: { name?: string; metadata?: Record }) { + return request(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, deleteProject(id: string) { return request(`/api/projects/${id}/`, { method: "DELETE" }); }, @@ -234,6 +238,10 @@ export const api = { deleteScriptSegment(projectId: string, payload: { segment_id: string }) { return request(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); }, + // 单条分镜重跑:后端只重写指定 segment(可带 instruction 微调),返回更新后的 ScriptVersion + rerunScriptSegment(projectId: string, payload: { segment_id: string; instruction?: string }) { + return request(`/api/projects/${projectId}/rerun-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); + }, adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) { return request(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) }); }, diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index c9d31a6..a78ecf4 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -45,11 +45,13 @@ gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border-faint); + background: transparent; } .msg-panel-h .ti { font-size: 13px; font-weight: 600; color: var(--accent-black); + background: none; } .msg-panel-h .mono { margin-left: auto; @@ -95,36 +97,28 @@ font-size: 12px; letter-spacing: .02em; } -.msg-search { - position: relative; +.msg-search-wrap { padding: 0 14px 12px; border-bottom: 1px solid var(--border-faint); } -.msg-search svg { +.msg-search-wrap .search-inline { + position: relative; +} +.msg-search-wrap .search-inline svg { position: absolute; - left: 26px; - top: 10px; - width: 13px; - height: 13px; - color: var(--black-alpha-48); + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + color: var(--black-alpha-56); pointer-events: none; + z-index: 2; } -.msg-search input { - width: 100%; +.msg-search-wrap .search-inline input.input { + padding-left: 36px; height: 34px; - padding: 0 12px 0 32px; - border: 1px solid var(--border-faint); - border-radius: var(--r-md); - background: var(--background-lighter); - color: var(--accent-black); - font-family: inherit; font-size: 13px; - outline: none; -} -.msg-search input:focus { - background: var(--surface); - border-color: var(--heat-40); - box-shadow: inset 0 0 0 1px var(--heat-40); } .msg-list { flex: 1; diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index bcf5ce1..ba2a1ff 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -488,7 +488,84 @@ .playhead::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%); width: 1.5px; background: var(--heat); pointer-events: none; } .playhead::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); width: 10px; height: 10px; background: var(--heat); box-shadow: 0 0 0 1.5px var(--surface); border-radius: 1px; pointer-events: none; } .playhead .ph-grab { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 24px; height: 24px; cursor: ew-resize; pointer-events: auto; border-radius: 50%; } + + /* ── 行34 · 脚本人物/场景可编辑 chip ── */ + .script-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px 2px 9px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 12px; color: var(--accent-black); } + .script-chip .chip-x { width: 15px; height: 15px; display: grid; place-items: center; background: transparent; border: 0; border-radius: 50%; color: var(--black-alpha-48); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; } + .script-chip .chip-x:hover { background: var(--black-alpha-08); color: var(--accent-crimson); } + .tag-add-input { height: 22px; width: 76px; padding: 0 8px; font-size: 12px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--heat); border-radius: var(--r-pill); outline: none; } + .tag-add-input::placeholder { color: var(--black-alpha-40); } + + /* ── 行33 · 进度提示流 ── */ + .progress-stream { display: flex; flex-direction: column; gap: 6px; } + .progress-stream .ps-row { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 1.5; animation: psRowIn .28s ease; } + .progress-stream .ps-row .ps-dot { width: 6px; height: 6px; margin-top: 6px; border-radius: 50%; background: var(--heat); flex: 0 0 6px; } + .progress-stream .ps-row.past { color: var(--black-alpha-48); } + .progress-stream .ps-row.past .ps-dot { background: var(--black-alpha-24); } + .progress-stream .ps-row.active { color: var(--accent-black); } + .progress-stream .ps-row.active .ps-dot { animation: prog-pulse 1.2s ease-in-out infinite; } + .progress-stream.done { display: inline-flex; flex-direction: row; align-items: center; gap: 7px; font-size: 13px; color: var(--accent-forest); } + .progress-stream.done .ps-check { width: 16px; height: 16px; display: grid; place-items: center; background: var(--forest-bg); color: var(--accent-forest); border-radius: 50%; flex: 0 0 16px; } + .progress-stream.done .ps-check svg { width: 10px; height: 10px; } + + /* ── 行32 · 长文本折叠 ── */ + .clamp-lines { display: -webkit-box; -webkit-line-clamp: var(--clamp-lines, 10); -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } + .clamp-toggle { margin-top: 6px; padding: 0; background: none; border: 0; color: var(--heat); font-family: inherit; font-size: 12px; cursor: pointer; } + .clamp-toggle:hover { text-decoration: underline; } + + /* ── 行28/行30 · 生成前「来源 & 风格 & 人物」设定卡 ── */ + .bubble.setup-card { width: 100%; max-width: 100%; display: flex; flex-direction: column; gap: 4px; } + .setup-card .setup-lead { font-size: 13px; line-height: 1.5; color: var(--accent-black); margin-bottom: 6px; } + .setup-card .setup-field { display: grid; grid-template-columns: 40px 1fr; align-items: center; gap: 10px; } + .setup-card .setup-field .sf-k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .04em; } + .setup-card .setup-select { height: 32px; width: 100%; padding: 0 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); outline: none; cursor: pointer; } + .setup-card .setup-select:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); } + .setup-card .setup-rec { grid-column: 2; margin-left: 50px; font-size: 12px; color: var(--black-alpha-40); margin: 0 0 8px 50px; } + .setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; } + + /* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */ + .draft-shot-card { background: var(--heat-12); border-radius: var(--r-md); border-bottom: 1px solid var(--heat-20); } + .draft-shot-card .shot-n { background: var(--heat); color: var(--accent-white); } + .draft-shot-input { width: 100%; font-family: inherit; font-size: 13px; line-height: 1.55; color: var(--accent-black); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 5px 8px; outline: none; resize: vertical; min-height: 30px; } + .draft-shot-input:focus { border-color: var(--heat); box-shadow: 0 0 0 2px var(--heat-12); } + .draft-shot-input::placeholder { color: var(--black-alpha-32); } + + /* ── 行38 · 资产卡可编辑提示词 + 重跑/替换 ── */ + .asset-prompt-edit { width: 100%; margin-top: 8px; font-family: var(--font-mono); font-size: 12px; line-height: 1.55; letter-spacing: .01em; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 8px 10px; outline: none; resize: vertical; min-height: 56px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); } + .asset-prompt-edit:hover { border-color: var(--heat-20); } + .asset-prompt-edit:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); } + .asset-prompt-edit::placeholder { color: var(--black-alpha-32); } + .asset-card-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; } + .asset-card-actions .btn { display: inline-flex; align-items: center; } + + /* ── 行39 · 缺三视图悬浮气泡 ── */ + .tri-missing-badge { position: absolute; top: 10px; left: 10px; z-index: 3; display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; background: var(--surface); border: 1px solid rgba(180,83,9,.30); border-radius: var(--r-pill); color: #B45309; font-size: 12px; cursor: help; } + .tri-missing-badge .ico { width: 6px; height: 6px; border-radius: 50%; background: #B45309; } + .tri-missing-badge .lbl-mono { font-family: var(--font-mono); letter-spacing: .02em; } + .tri-missing-badge .tri-missing-pop { position: absolute; top: calc(100% + 8px); left: 0; width: 260px; padding: 12px 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 12px 32px rgba(0,0,0,.14); opacity: 0; visibility: hidden; transform: translateY(-4px); transition: opacity var(--t-base), transform var(--t-base), visibility var(--t-base); z-index: 20; pointer-events: none; display: flex; flex-direction: column; gap: 6px; cursor: default; } + .tri-missing-badge:hover .tri-missing-pop, .tri-missing-badge:focus-visible .tri-missing-pop { opacity: 1; visibility: visible; transform: translateY(0); } + .tri-missing-pop .pop-h { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; color: #B45309; } + .tri-missing-pop .pop-body { font-size: 12px; line-height: 1.55; color: var(--accent-black); } + .tri-missing-pop .pop-tip { font-size: 12px; line-height: 1.5; color: var(--black-alpha-56); } + .tri-missing-pop b { color: var(--heat); } + + /* ── 行39 · 三视图选择弹窗 ── */ + .tri-modal { width: min(720px, 100%); } + .tri-modal .tri-modal-tip { font-size: 13px; line-height: 1.55; color: var(--black-alpha-72); padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); margin-bottom: 16px; } + .tri-modal .tri-modal-tip b { color: var(--heat); } + .tri-cand-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } + .tri-cand-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; cursor: pointer; background: var(--surface); transition: border-color var(--t-base), box-shadow var(--t-base); } + .tri-cand-card:hover { border-color: var(--heat); box-shadow: 0 1px 3px rgba(0,0,0,.04); } + .tri-cand-card.adopted { border-color: var(--heat); } + .tri-cand-card .tri-cand-img { aspect-ratio: 16/9; } + .tri-cand-card .tri-cand-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; } + .tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); } + .tri-empty { text-align: center; padding: 8px 0; } + .tri-empty .tri-empty-hint { font-size: 13px; line-height: 1.6; color: var(--black-alpha-56); margin-top: 14px; } } +/* 进度流逐条滚入(顶层 @keyframes 才注册) */ +@keyframes psRowIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + /* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */ @keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } } diff --git a/core/frontend/src/routes/messages.tsx b/core/frontend/src/routes/messages.tsx index ffdb22d..dc721a0 100644 --- a/core/frontend/src/routes/messages.tsx +++ b/core/frontend/src/routes/messages.tsx @@ -183,9 +183,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate ))} -
- - setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /> +
+
+ + setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /> +
{items.length === 0 && !loading ? ( diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index de21047..45e16ae 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -234,6 +234,118 @@ const STAGE_STEPS: Array<{ n: number; label: string }> = [ { n: 5, label: "拼接导出" } ]; +// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起) +function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: string) => void; placeholder?: string; ariaLabel?: string }) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(""); + const inputRef = useRef(null); + useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]); + const commit = () => { + const v = value.trim(); + if (v) onAdd?.(v); + setValue(""); + setEditing(false); + }; + if (!editing) { + return ; + } + return ( + setValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { setValue(""); setEditing(false); } }} + onBlur={commit} + /> + ); +} + +// 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库 +function DraftShotCard({ draft, onCommit, onCancel }: { + draft: { id: string; afterId: string | null; narration: string; visual: string }; + onCommit?: (draft: { id: string; afterId: string | null; narration: string; visual: string }) => void; + onCancel?: (id: string) => void; +}) { + const [narration, setNarration] = useState(draft.narration); + const [visual, setVisual] = useState(draft.visual); + const naRef = useRef(null); + useEffect(() => { naRef.current?.focus(); }, []); + const commit = () => onCommit?.({ ...draft, narration: narration.trim(), visual: visual.trim() }); + return ( +
+
+
+
+
// 新分镜 · 待填写
+
+ + +
+
+
+ 旁白 + +
-
@@ -1613,7 +1927,8 @@ export function PipelinePage(props: {
{(project.created_at || "").slice(0, 10)} 创建
- @@ -1651,6 +1966,8 @@ export function PipelinePage(props: { {list.map((group, gi) => { const mainUrl = groupMainUrl(group); const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4); + // 行38 · 可编辑提示词:草稿优先,落空回退原 prompt;重跑/替换都带它 + const promptValue = assetPromptDraft[group.id] ?? group.prompt ?? ""; return (
@@ -1664,8 +1981,14 @@ export function PipelinePage(props: { ? 已采用 : 待采用}
- {/* 提示词只读展示:此前是 contentEditable 但编辑结果无人消费,纯欺骗性交互 */} -
{group.prompt || "(暂无提示词)"}
+ {/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */} +