fix(core): 测试清单 行24/28-39/46 — 流水线脚本&资产/消息页 + 单镜重跑后端接口
前端流水线(pipeline.tsx):脚本助手三选项指引(AI全生/一句话/自带脚本)+来源风格推荐;
风格/人物设定卡并持久化到 project.metadata;Enter发送/Shift+Enter换行;长文折叠10行;
生成进度流提示;分镜人物/场景标签可编辑并持久化+点击添加分镜;单镜重跑/删除即时反馈;
脚本助手记录按项目 localStorage 持久化;Stage2 基础资产补替换/重跑/可编辑提示词;
三视图采用弹窗+缺三视图气泡。
消息页(messages):复用全站 .search-inline 搜索框、去掉小标题背景方块。
视频项目(projects):列表按创建时间倒序,新建置顶。
性能:action 改后台 hydrate,消除创建/确认后白等(行29/37)。
后端(projects/ai):新增单镜 AI 重跑接口 POST /projects/{id}/rerun-script-segment/
(regenerate_script_segment 服务,复用 AITask+计费 reserve/charge/release;+2 单测)。
前端 build 通过;后端 check 0/无新迁移/apps.projects 14 测试通过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }), "基础资产已生成")}
|
||||
|
||||
@@ -210,6 +210,10 @@ export const api = {
|
||||
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
|
||||
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteProject(id: string) {
|
||||
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
@@ -234,6 +238,10 @@ export const api = {
|
||||
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
|
||||
return request<ScriptVersion>(`/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<ScriptVersion>(`/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<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -183,9 +183,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="msg-search">
|
||||
<Search />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
<div className="msg-search-wrap">
|
||||
<div className="search-inline">
|
||||
<Search />
|
||||
<input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||
{items.length === 0 && !loading ? (
|
||||
|
||||
@@ -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<HTMLInputElement | null>(null);
|
||||
useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]);
|
||||
const commit = () => {
|
||||
const v = value.trim();
|
||||
if (v) onAdd?.(v);
|
||||
setValue("");
|
||||
setEditing(false);
|
||||
};
|
||||
if (!editing) {
|
||||
return <button className="tag-add" type="button" aria-label={ariaLabel} onClick={() => setEditing(true)}>+</button>;
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="tag-add-input"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => 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<HTMLTextAreaElement | null>(null);
|
||||
useEffect(() => { naRef.current?.focus(); }, []);
|
||||
const commit = () => onCommit?.({ ...draft, narration: narration.trim(), visual: visual.trim() });
|
||||
return (
|
||||
<div className="shot-card draft-shot-card">
|
||||
<div className="shot-n">+</div>
|
||||
<div className="shot-main">
|
||||
<div className="shot-meta-row">
|
||||
<div className="shot-meta">// 新分镜 · 待填写</div>
|
||||
<div className="shot-actions">
|
||||
<button className="icon-mini-btn" type="button" title="确认新增" onClick={commit}>✓</button>
|
||||
<button className="icon-mini-btn" type="button" title="取消" onClick={() => onCancel?.(draft.id)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shot-row">
|
||||
<span className="shot-k">旁白</span>
|
||||
<textarea ref={naRef} className="draft-shot-input" rows={1} placeholder="输入这一镜的旁白…" value={narration}
|
||||
onChange={(e) => setNarration(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
|
||||
</div>
|
||||
<div className="shot-row">
|
||||
<span className="shot-k">画面</span>
|
||||
<textarea className="draft-shot-input" rows={1} placeholder="输入这一镜的画面描述…" value={visual}
|
||||
onChange={(e) => setVisual(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 行33 · 进度提示流气泡:steps 逐条滚动;done 后折叠成一行「已完成」结果
|
||||
function ProgressStream({ steps, done }: { steps?: string[]; done?: boolean }) {
|
||||
const list = steps ?? [];
|
||||
if (done) {
|
||||
return (
|
||||
<div className="progress-stream done">
|
||||
<span className="ps-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
已完成 {list.length} 步思考 · 镜头脚本已生成
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="progress-stream">
|
||||
{list.map((step, i) => {
|
||||
const isLast = i === list.length - 1;
|
||||
return (
|
||||
<div className={`ps-row${isLast ? " active" : " past"}`} key={i}>
|
||||
<span className="ps-dot" aria-hidden="true"></span>
|
||||
<span className="ps-text">{step}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 行32 · 长文本折叠:超过 maxLines 行先折叠,点「查看更多」展开
|
||||
function CollapsibleText({ text, maxLines = 10 }: { text: string; maxLines?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
|
||||
// 长文本不一定有换行(整段粘贴),按字符量也判一次溢出
|
||||
const overflow = lineCount > maxLines || text.length > maxLines * 40;
|
||||
if (!overflow) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
<div className={expanded ? "" : "clamp-lines"} style={expanded ? undefined : ({ ["--clamp-lines"]: String(maxLines) } as CSSProperties)}>{text}</div>
|
||||
<button type="button" className="clamp-toggle" onClick={() => setExpanded((v) => !v)}>{expanded ? "收起" : "查看更多"}</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PipelinePage(props: {
|
||||
project: Project;
|
||||
loading: boolean;
|
||||
@@ -254,6 +366,9 @@ export function PipelinePage(props: {
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string) => Promise<unknown>;
|
||||
onDeleteShot: (segmentId: string) => Promise<unknown>;
|
||||
// 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化
|
||||
onRerunShot?: (segmentId: string, instruction?: string) => Promise<unknown>;
|
||||
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
|
||||
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
|
||||
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
|
||||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
|
||||
@@ -273,7 +388,7 @@ export function PipelinePage(props: {
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
@@ -303,6 +418,19 @@ export function PipelinePage(props: {
|
||||
null;
|
||||
const scriptAdopted = Boolean(currentScript?.is_adopted);
|
||||
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
// 行34 · 脚本里的「人物 / 场景」标签:持久化进 project.metadata.cast / .scenes。
|
||||
// 初值从 metadata 读,刷新/重进后仍在;增删后 onSaveProjectMeta 合并落库。
|
||||
const [castTags, setCastTags] = useState<string[]>(() => project.metadata?.cast ?? []);
|
||||
const [sceneTags, setSceneTags] = useState<string[]>(() => project.metadata?.scenes ?? []);
|
||||
// 后台刷新(action 后 refreshProjectDetail)回填的 metadata 同步到本地态,保证多端/重进一致
|
||||
useEffect(() => { setCastTags(project.metadata?.cast ?? []); }, [project.metadata?.cast]);
|
||||
useEffect(() => { setSceneTags(project.metadata?.scenes ?? []); }, [project.metadata?.scenes]);
|
||||
// 标签增删:先本地更新(即时反馈)再合并落库
|
||||
const saveCastTags = (next: string[]) => { setCastTags(next); void onSaveProjectMeta?.({ cast: next }); };
|
||||
const saveSceneTags = (next: string[]) => { setSceneTags(next); void onSaveProjectMeta?.({ scenes: next }); };
|
||||
// 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot)
|
||||
type DraftShot = { id: string; afterId: string | null; narration: string; visual: string };
|
||||
const [draftShots, setDraftShots] = useState<DraftShot[]>([]);
|
||||
|
||||
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
|
||||
// 后端 BaseAssetGroup 无 Meta.ordering(UUID 主键顺序≈随机),必须按 created_at 排稳,
|
||||
@@ -311,6 +439,11 @@ export function PipelinePage(props: {
|
||||
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
|
||||
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
|
||||
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
|
||||
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
|
||||
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
|
||||
// 行39 · AI 生成三视图弹窗:打开后从该商品组选择采用哪个候选三视图(没有也可跑视频)
|
||||
const [triViewOpen, setTriViewOpen] = useState(false);
|
||||
useBodyScrollLock(triViewOpen);
|
||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||
setAssetTab(kind);
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -392,20 +525,73 @@ export function PipelinePage(props: {
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
|
||||
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
|
||||
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
|
||||
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupSource, setSetupSource] = useState<"ai" | "theme" | "manual">("ai");
|
||||
const [setupStyle, setSetupStyle] = useState<string>(project.metadata?.wizard?.script_style || SETUP_STYLE_KEYS[0] || "pain");
|
||||
const [setupPersona, setSetupPersona] = useState<string>(project.metadata?.wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
||||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
|
||||
type ChatMsg = { role: "ai" | "user"; text: string; time: string };
|
||||
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
|
||||
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; done?: boolean };
|
||||
const nowHm = () => new Date().toTimeString().slice(0, 5);
|
||||
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() =>
|
||||
currentScript
|
||||
? [{ role: "ai", text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
|
||||
: []
|
||||
);
|
||||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { role, text, time: nowHm() }]);
|
||||
const msgIdRef = useRef(1);
|
||||
const nextMsgId = () => msgIdRef.current++;
|
||||
// 脚本助手记录按项目持久化:退出项目→重进仍能看到历史对话(行36)
|
||||
const chatKey = `airshelf:pipeline:chat:${project.id}`;
|
||||
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(chatKey);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as ChatMsg[];
|
||||
if (Array.isArray(saved) && saved.length) {
|
||||
msgIdRef.current = Math.max(...saved.map((m) => m.id || 0)) + 1;
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
} catch { /* 解析失败则回退默认 */ }
|
||||
return currentScript
|
||||
? [{ id: nextMsgId(), role: "ai", text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
|
||||
: [];
|
||||
});
|
||||
// 落盘:丢掉未完成的 progress 流(那是临时态),只保留最近 60 条
|
||||
useEffect(() => {
|
||||
try {
|
||||
const slim = chatMsgs.filter((m) => m.kind !== "progress" || m.done).slice(-60);
|
||||
localStorage.setItem(chatKey, JSON.stringify(slim));
|
||||
} catch { /* localStorage 不可用则忽略 */ }
|
||||
}, [chatKey, chatMsgs]);
|
||||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
|
||||
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
|
||||
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
||||
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
|
||||
const [busyShot, setBusyShot] = useState<string | null>(null);
|
||||
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
|
||||
async function rerunShot(shotId: string, _index: number, hint: string) {
|
||||
if (busyShot) return;
|
||||
setBusyShot(shotId);
|
||||
try {
|
||||
await onRerunShot?.(shotId, hint || undefined);
|
||||
} finally {
|
||||
setBusyShot(null);
|
||||
}
|
||||
}
|
||||
// 单条分镜删除:立即反馈(置 busy)再落库
|
||||
async function deleteShot(shotId: string) {
|
||||
if (busyShot) return;
|
||||
setBusyShot(shotId);
|
||||
setArmedDelete(null);
|
||||
try {
|
||||
await onDeleteShot(shotId);
|
||||
} finally {
|
||||
setBusyShot(null);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!armedDelete) return;
|
||||
const timer = window.setTimeout(() => setArmedDelete(null), 3000);
|
||||
@@ -414,20 +600,75 @@ export function PipelinePage(props: {
|
||||
useEffect(() => {
|
||||
const el = chatBodyRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [chatMsgs.length]);
|
||||
// 统一的脚本生成对话回合:用户消息 → 生成中 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
|
||||
}, [chatMsgs]);
|
||||
// 行33 · 进度提示流:前端模拟 AI 分步思考(逐条滚动),不需要真后端分步。
|
||||
// 生成期间逐条往同一条 progress 消息追加 step;onGenerateScript 返回后置 done 折叠收起。
|
||||
const PROGRESS_STEPS = [
|
||||
"收到脚本,正在解析商品卖点与创作方向…",
|
||||
"提取关键卖点 · 锁定目标人群画像…",
|
||||
"匹配创作风格与镜头节奏…",
|
||||
"编排分镜 · 旁白与画面逐镜成稿…",
|
||||
"校对时长与转化点,整理输出…"
|
||||
];
|
||||
// 统一的脚本生成对话回合:用户消息 → 进度流 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
|
||||
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string) {
|
||||
pushMsg("user", userLabel || prompt);
|
||||
pushMsg("ai", "正在解析商品卖点与创作方向,生成镜头脚本…");
|
||||
const progressId = nextMsgId();
|
||||
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [PROGRESS_STEPS[0]], done: false, time: nowHm() }]);
|
||||
// 逐条滚出后续步骤(纯前端节奏,生成真完成时收口)
|
||||
let stepIdx = 1;
|
||||
const timer = window.setInterval(() => {
|
||||
if (stepIdx >= PROGRESS_STEPS.length) { window.clearInterval(timer); return; }
|
||||
const step = PROGRESS_STEPS[stepIdx];
|
||||
stepIdx += 1;
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId && !m.done ? { ...m, steps: [...(m.steps ?? []), step] } : m)));
|
||||
}, 900);
|
||||
const res = await onGenerateScript(prompt, source ?? chatMode);
|
||||
window.clearInterval(timer);
|
||||
// 收口:把 progress 折叠成一行结果,并补一条结果文本
|
||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
|
||||
pushMsg("ai", res ? "镜头脚本已生成,左侧已刷新。可继续输入修改意见整体重写,或点底部「确认脚本」进入下一步。" : "生成没有成功,请查看提示后重试。");
|
||||
}
|
||||
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
||||
async function runScriptWithSetup() {
|
||||
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
|
||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||
setSetupOpen(false);
|
||||
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids);
|
||||
// 顶部「风格/人物」brief pill 读 metadata.wizard,刷新后即显已确认值。
|
||||
// 先 await 落库再发起生成:action 有 in-flight 互斥锁,并发会被「操作进行中」挡掉
|
||||
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
|
||||
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
|
||||
if (setupSource === "theme") {
|
||||
const theme = chatText.trim();
|
||||
if (!theme) { focusThemeMode(); return; }
|
||||
setChatText("");
|
||||
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
|
||||
return;
|
||||
}
|
||||
if (setupSource === "manual") {
|
||||
const base = chatText.trim();
|
||||
setChatText("");
|
||||
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
|
||||
return;
|
||||
}
|
||||
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
|
||||
}
|
||||
function clearChat() {
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
setChatMode("ai");
|
||||
setSetupOpen(false);
|
||||
setChatMsgs([]);
|
||||
}
|
||||
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
|
||||
function openSetup(source: "ai" | "theme" | "manual") {
|
||||
setSetupSource(source);
|
||||
setChatMode(source);
|
||||
setSetupOpen(true);
|
||||
if (source === "theme") { focusThemeMode(); }
|
||||
else if (source === "manual") { pickScriptMode(); }
|
||||
}
|
||||
function pickScriptMode() {
|
||||
setChatMode("manual");
|
||||
chatFileRef.current?.click();
|
||||
@@ -1391,14 +1632,27 @@ export function PipelinePage(props: {
|
||||
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
</div>
|
||||
{/* 行34 · 该脚本的人物 / 场景标签:可编辑(增删 chip);出现分镜后才展示 */}
|
||||
<div className="script-tags" id="script-tags">
|
||||
<div className="tag-group" data-kind="char">
|
||||
<span className="tg-lbl">// 人物</span>
|
||||
<button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
|
||||
{castTags.map((tag, i) => (
|
||||
<span className="script-chip" key={`cast-${i}-${tag}`}>
|
||||
{tag}
|
||||
<button type="button" className="chip-x" aria-label={`移除人物 ${tag}`} onClick={() => saveCastTags(castTags.filter((_, j) => j !== i))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<AddTagInline placeholder="人物名" onAdd={(v) => { if (!castTags.includes(v)) saveCastTags([...castTags, v]); }} ariaLabel="添加人物" />
|
||||
</div>
|
||||
<div className="tag-group" data-kind="scene">
|
||||
<span className="tg-lbl">// 场景</span>
|
||||
<button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
|
||||
{sceneTags.map((tag, i) => (
|
||||
<span className="script-chip" key={`scene-${i}-${tag}`}>
|
||||
{tag}
|
||||
<button type="button" className="chip-x" aria-label={`移除场景 ${tag}`} onClick={() => saveSceneTags(sceneTags.filter((_, j) => j !== i))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<AddTagInline placeholder="场景名" onAdd={(v) => { if (!sceneTags.includes(v)) saveSceneTags([...sceneTags, v]); }} ariaLabel="添加场景" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
@@ -1422,16 +1676,16 @@ export function PipelinePage(props: {
|
||||
<div className="shot-meta-row">
|
||||
<div className="shot-meta">// 场 {index + 1} · {start}-{cum}s</div>
|
||||
<div className="shot-actions">
|
||||
<button className="icon-mini-btn" type="button" title="重写本场(把修改意见发给脚本助手)" disabled={loading} onClick={() => { focusThemeMode(); setChatText(`修改第 ${index + 1} 镜:`); }}>↻</button>
|
||||
{/* 行35 · 单条重跑:即时反馈(转「…」并禁用) */}
|
||||
<button className="icon-mini-btn" type="button" title="重跑本场(只重写这一镜)" disabled={loading || busyShot === shot.id} onClick={() => void rerunShot(shot.id, index, (visualShown || narration))}>{busyShot === shot.id ? "…" : "↻"}</button>
|
||||
<button
|
||||
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
|
||||
type="button"
|
||||
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
|
||||
disabled={loading || shots.length <= 1}
|
||||
disabled={loading || shots.length <= 1 || busyShot === shot.id}
|
||||
onClick={() => {
|
||||
if (armedDelete === shot.id) {
|
||||
setArmedDelete(null);
|
||||
void onDeleteShot(shot.id);
|
||||
void deleteShot(shot.id);
|
||||
} else {
|
||||
setArmedDelete(shot.id);
|
||||
}
|
||||
@@ -1449,6 +1703,7 @@ export function PipelinePage(props: {
|
||||
data-placeholder="(旁白)点击编辑"
|
||||
data-empty={narration ? undefined : "true"}
|
||||
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
|
||||
onBlur={(event) => {
|
||||
const text = (event.currentTarget.textContent || "").trim();
|
||||
if (!text) event.currentTarget.setAttribute("data-empty", "true");
|
||||
@@ -1466,6 +1721,7 @@ export function PipelinePage(props: {
|
||||
data-placeholder="(画面描述)点击编辑"
|
||||
data-empty={visualShown ? undefined : "true"}
|
||||
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
|
||||
onBlur={(event) => {
|
||||
const text = (event.currentTarget.textContent || "").trim();
|
||||
if (!text) event.currentTarget.setAttribute("data-empty", "true");
|
||||
@@ -1475,8 +1731,14 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */}
|
||||
{draftShots.filter((d) => d.afterId === shot.id).map((draft) => (
|
||||
<DraftShotCard key={draft.id} draft={draft}
|
||||
onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id); }}
|
||||
onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} />
|
||||
))}
|
||||
<div className="shot-insert-gap">
|
||||
<button className="add-shot-btn" type="button" disabled={loading} onClick={() => void onAddShot(shot.id)}>
|
||||
<button className="add-shot-btn" type="button" disabled={loading} onClick={() => setDraftShots((list) => [...list, { id: `draft-${Date.now()}`, afterId: shot.id, narration: "", visual: "" }])}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>添加分镜
|
||||
</button>
|
||||
</div>
|
||||
@@ -1504,19 +1766,58 @@ export function PipelinePage(props: {
|
||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
|
||||
</div>
|
||||
<div className="chat-body" id="chat-body" ref={chatBodyRef}>
|
||||
{chatMsgs.length ? chatMsgs.map((msg, index) => (
|
||||
<div className={`msg ${msg.role}`} key={index}>
|
||||
<div className="bubble">{msg.text}</div>
|
||||
<div className="time">{msg.time}</div>
|
||||
</div>
|
||||
)) : (
|
||||
{chatMsgs.length || setupOpen ? (
|
||||
<>
|
||||
{chatMsgs.map((msg) => (
|
||||
<div className={`msg ${msg.role}`} key={msg.id}>
|
||||
<div className="bubble">
|
||||
{msg.kind === "progress"
|
||||
? <ProgressStream steps={msg.steps} done={msg.done} />
|
||||
: <CollapsibleText text={msg.text} maxLines={10} />}
|
||||
</div>
|
||||
<div className="time">{msg.time}</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
|
||||
{setupOpen && (
|
||||
<div className="msg ai">
|
||||
<div className="bubble setup-card">
|
||||
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">风格</span>
|
||||
<select className="setup-select" value={setupStyle} onChange={(e) => setSetupStyle(e.target.value)}>
|
||||
{SETUP_STYLE_KEYS.map((k) => <option key={k} value={k}>{WIZ_STYLE_LABEL[k]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">根据商品信息推荐</div>
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">人物</span>
|
||||
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
|
||||
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">根据商品目标人群推荐</div>
|
||||
<div className="setup-foot">
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => {
|
||||
// 重新推荐:随机换一组,模拟「再推荐」(纯前端,后端会从 prompt 推断)
|
||||
setSetupStyle(SETUP_STYLE_KEYS[Math.floor(Math.random() * SETUP_STYLE_KEYS.length)] || setupStyle);
|
||||
setSetupPersona(SETUP_PERSONA_KEYS[Math.floor(Math.random() * SETUP_PERSONA_KEYS.length)] || setupPersona);
|
||||
}}>重新推荐</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time">{nowHm()}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="chat-empty">
|
||||
<div className="ce-title">选择一种生成方式开始</div>
|
||||
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
||||
<div className="chat-modes">
|
||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => { setChatMode("ai"); void runScriptGeneration("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放", "AI 全生:根据商品信息直接生成第一版", "ai"); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
||||
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={focusThemeMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={pickScriptMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
|
||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
||||
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={() => openSetup("theme")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1532,14 +1833,27 @@ export function PipelinePage(props: {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
|
||||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜 ·(Enter 发送 / Shift+Enter 换行)" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本 ·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
if (setupOpen) { void runScriptWithSetup(); return; }
|
||||
const text = chatText.trim();
|
||||
if (!text) return;
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
void runScriptGeneration(text);
|
||||
}
|
||||
}}></textarea>
|
||||
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
|
||||
<div className="chat-input-foot">
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { const text = chatText.trim(); setChatText(""); setChatAttachments([]); void runScriptGeneration(text); }}>
|
||||
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim())} onClick={() => { if (setupOpen) { void runScriptWithSetup(); return; } const text = chatText.trim(); if (!text) return; setChatText(""); setChatAttachments([]); void runScriptGeneration(text); }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1613,7 +1927,8 @@ export function PipelinePage(props: {
|
||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
|
||||
{/* 行39 · 点开弹窗:可选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => setTriViewOpen(true)}>
|
||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||||
AI 生成三视图
|
||||
</button>
|
||||
@@ -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 (
|
||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
|
||||
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
|
||||
@@ -1664,8 +1981,14 @@ export function PipelinePage(props: {
|
||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
||||
</div>
|
||||
{/* 提示词只读展示:此前是 contentEditable 但编辑结果无人消费,纯欺骗性交互 */}
|
||||
<div className="prompt-box">{group.prompt || "(暂无提示词)"}</div>
|
||||
{/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */}
|
||||
<textarea
|
||||
className="asset-prompt-edit"
|
||||
rows={3}
|
||||
placeholder="描述这个素材的提示词…"
|
||||
value={promptValue}
|
||||
onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [group.id]: e.target.value }))}
|
||||
/>
|
||||
{cands.length > 0 && (
|
||||
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
|
||||
{cands.map((id) => (
|
||||
@@ -1673,6 +1996,15 @@ export function PipelinePage(props: {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 行38 · 重跑 / 替换:重跑=据当前提示词重新生成;替换=有候选先采下一张,无候选回退重生成 */}
|
||||
<div className="asset-card-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt))}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
|
||||
重跑
|
||||
</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => { if (cands.length) { void onAdoptBaseAsset(group.id, cands[0]); } else { onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt)); } }}>替换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1696,6 +2028,56 @@ export function PipelinePage(props: {
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行39 · AI 生成三视图弹窗:选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
|
||||
{triViewOpen && (
|
||||
<div className="asset-modal-bg" onClick={() => setTriViewOpen(false)}>
|
||||
<div className="asset-modal tri-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="asset-modal-h">
|
||||
<h2>AI 生成三视图</h2>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={() => setTriViewOpen(false)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="asset-modal-body">
|
||||
<div className="tri-modal-tip">
|
||||
建议先生成 <b>正 / 侧 / 背</b> 三视图,后续生成的角色一致性与姿态稳定性更好;但没有三视图也可直接跑视频。
|
||||
</div>
|
||||
{productCandidates.length ? (
|
||||
<>
|
||||
<div className="vd-history-h">// 候选三视图 · 选择采用哪个版本</div>
|
||||
<div className="tri-cand-grid">
|
||||
{productCandidates.map((id, i) => {
|
||||
const u = candUrl(productGroup, id);
|
||||
return (
|
||||
<div className={`tri-cand-card${productGroup?.adopted_asset === id ? " adopted" : ""}`} key={id} role="button" tabIndex={0}
|
||||
onClick={() => { if (productGroup) { void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}
|
||||
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && productGroup) { e.preventDefault(); void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}>
|
||||
<div className={`placeholder tri-cand-img${u ? " has-mock-media" : ""}`} style={u ? mediaStyle(u) : undefined}><span className="ph-frame">版本 {i + 1}</span></div>
|
||||
<div className="tri-cand-foot"><span className="mono">// V{i + 1}</span><span className="btn btn-ghost btn-sm">采用此版本</span></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="tri-empty">
|
||||
<div className="placeholder tri-cand-img" style={{ maxWidth: 220, margin: "0 auto" }}><span className="ph-frame">// 暂无三视图候选</span></div>
|
||||
<p className="tri-empty-hint">还没有候选三视图。点下方「生成三视图」让 AI 出多角度参考图,生成后回此处选择采用。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-modal-f">
|
||||
<button className="btn" type="button" onClick={() => setTriViewOpen(false)}>暂不生成(直接跑视频)</button>
|
||||
<span className="spacer" style={{ flex: 1 }}></span>
|
||||
<button className="btn-aigen" type="button" disabled={loading} onClick={() => { onGenerateBaseAsset("product", `${productName} 三视图`); setTriViewOpen(false); }}>
|
||||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /></svg>
|
||||
生成三视图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -528,6 +528,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
if (days > Number(timeFilter)) return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
// 倒序排列:新创建的项目排最前;created_at 缺失时回退 updated_at,再回退 id 字典序
|
||||
const ta = a.created_at || a.updated_at || a.id;
|
||||
const tb = b.created_at || b.updated_at || b.id;
|
||||
return ta < tb ? 1 : ta > tb ? -1 : 0;
|
||||
});
|
||||
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -187,7 +187,11 @@ export type Project = {
|
||||
}>;
|
||||
storyboard_versions: StoryboardVersion[];
|
||||
timeline: Timeline | null;
|
||||
metadata?: { wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] } } & Record<string, unknown>;
|
||||
metadata?: {
|
||||
wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };
|
||||
cast?: string[];
|
||||
scenes?: string[];
|
||||
} & Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user