大量修改二期功能清单内容

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
@@ -3,6 +3,7 @@ import { Gauge, Wallet, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
import { pts } from "../stage-config";
@@ -175,7 +176,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3></h3><p>// fetching ledgers</p></div>
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
) : ledgers.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3></h3><p>// no ledgers</p></div>
) : (
@@ -331,7 +332,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3></h3><p>// fetching policies</p></div>
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
) : policies.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3></h3><p>// 默认仅余额管控 · 点右上新建</p></div>
) : (
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminIntegrity, AdminProjectRow } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -107,7 +108,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3></h3><p>// fetching projects</p></div>
<SystemLoading title="正在加载项目" description="正在同步最新数据,请稍候。" icon="clapperboard" />
) : projects.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3></h3><p>// no projects</p></div>
) : (
@@ -3,6 +3,7 @@ import { Ticket, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { Invitation } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -129,7 +130,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div><h3></h3><p>// fetching invitations</p></div>
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="ticket" />
) : invites.length === 0 ? (
<div className="empty-state show">
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Server, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminModel, AdminProvider } from "../../types";
import { pts } from "../stage-config";
@@ -128,7 +128,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="server" size={24} /></div><h3></h3><p>// fetching providers</p></div>
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="server" />
) : (
<>
<div className="admin-detail-subhead"></div>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminPromptTemplate } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -68,7 +68,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
</div>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3></h3><p>// fetching prompt templates</p></div>
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
) : (
<div className="pt-list">
{rows.map((r) => {
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminQualityWord } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -84,7 +84,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3></h3><p>// fetching quality words</p></div>
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
) : (
<div className="qw-stages">
{STAGES.map((s) => {
@@ -3,6 +3,8 @@ import { RefreshCw } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import { MediaLightbox } from "../../components/overlays";
import type { AdminReviewAsset } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
@@ -32,6 +34,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
const [tab, setTab] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -113,7 +116,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3></h3><p>// fetching review queue</p></div>
<SystemLoading title="正在加载审核数据" description="正在同步最新数据,请稍候。" icon="shield" />
) : assets.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3></h3><p>// no person assets</p></div>
) : (
@@ -135,7 +138,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
<td>
{a.preview_url
? <img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
? (
<button
type="button"
className="admin-thumb-btn"
title="点击放大"
aria-label={`放大预览 ${a.name || "资产"}`}
onClick={() => setPreview({ src: a.preview_url, name: a.name || "预览" })}
>
<img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
</button>
)
: <span className="admin-thumb admin-thumb-empty" />}
</td>
<td>{a.team_name || <span className="muted"></span>}</td>
@@ -160,12 +173,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
)}
{selected.size > 0 && (
<div className="bulk-bar show">
<span className="bulk-count"> {selected.size} </span>
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
<span className="admin-bulk-count"> {selected.size} </span>
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}></button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}></button>
</div>
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</>
);
}
@@ -3,6 +3,7 @@ import { Activity, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminTask, AdminTaskDetail } from "../../types";
import { pts } from "../stage-config";
@@ -127,7 +128,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3><p>// fetching tasks</p></div>
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
) : tasks.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3><p>// no tasks</p></div>
) : (
@@ -174,7 +175,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
</div>
<div className="drawer-b">
{detailLoading || !detail ? (
<p className="admin-modal-desc"></p>
<SystemLoading variant="inline" title="正在加载详情" />
) : (
<>
{(() => {
@@ -3,6 +3,7 @@ import { Building, KeyRound, Percent, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import { SystemLoading } from "../../components/loading";
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
import { pts } from "../stage-config";
@@ -128,7 +129,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3><p>// fetching teams</p></div>
<SystemLoading title="正在加载团队数据" description="正在同步最新数据,请稍候。" icon="building" />
) : teams.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3><p>// no teams</p></div>
) : (
@@ -198,7 +199,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
</div>
<div className="modal-b">
{detailLoading || !detail ? (
<p className="admin-modal-desc"></p>
<SystemLoading variant="inline" title="正在加载详情" icon="building" />
) : (
<>
<div className="admin-detail-meta">
@@ -311,7 +312,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3><p>// fetching users</p></div>
<SystemLoading title="正在加载用户数据" description="正在同步最新数据,请稍候。" icon="users" />
) : users.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3><p>// no users</p></div>
) : (
+2 -2
View File
@@ -31,7 +31,7 @@ import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConf
import { api } from "../api";
import { imageModelPickerOptions } from "../model-display";
import { ModelLibrary } from "../components/model-library";
import { SkeletonRows } from "../components/loading";
import { SkeletonRows, SystemLoading } from "../components/loading";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
@@ -461,7 +461,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
</div>
<div className="modal-b">
{batchLoading ? (
<div className="task-empty"><div className="mono">// LOADING…</div></div>
<SystemLoading variant="inline" title="正在加载详情" icon="images" />
) : batchImgs.length === 0 ? (
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
) : (
+33 -4
View File
@@ -2,7 +2,7 @@
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Users } from "lucide-react";
import { Images, Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
@@ -23,7 +23,9 @@ import { FreeInputBar } from "../components/free-create/input-bar";
import { GenerationCard } from "../components/free-create/generation-card";
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
import { AssetLibraryModal } from "../components/free-create/asset-library-modal";
import { PlatformLibraryModal } from "../components/free-create/platform-library-modal";
import type { PromptInputHandle } from "../components/free-create/prompt-input";
import { SystemLoading } from "../components/loading";
import { ConfirmModal } from "../components/overlays";
const PAGE_SIZE = 20;
@@ -96,6 +98,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [platformOpen, setPlatformOpen] = useState(false);
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
@@ -468,6 +471,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
setLibraryTargetRole(null);
}, []);
// 三库(资产库/模特库/商品库)引用:与人物素材库共用槽位逻辑,只是数据源不同。
// 首尾帧模式没指定槽位时按拖拽同一规则落位:首帧空就填首帧,否则填尾帧。
const openPlatformLibrary = useCallback((role?: "first_frame" | "last_frame") => {
const target = mode === "keyframe"
? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame")
: null;
setLibraryTargetRole(target);
setPlatformOpen(true);
}, [mode, refs]);
const closePlatformLibrary = useCallback(() => {
setPlatformOpen(false);
setLibraryTargetRole(null);
}, []);
// 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
if (mode === "keyframe") {
@@ -483,8 +501,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
...ref,
key: nextRefKey(),
role: libraryTargetRole,
label: undefined,
source: "library"
label: undefined
};
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
setLibraryTargetRole(null);
@@ -517,6 +534,9 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => openPlatformLibrary()}>
<Images size={14} />
</button>
<button type="button" className="btn" onClick={() => openLibrary()}>
<Users size={14} />
</button>
@@ -525,7 +545,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
<div className="fc-feed-wrap">
{loading ? (
<div className="fc-loading mono">// 加载中…</div>
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
) : tasks.length === 0 ? (
<div className="empty-state show fc-empty">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
@@ -572,6 +592,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={openLibrary}
onOpenPlatformLibrary={openPlatformLibrary}
onClear={clearInput}
onSend={() => void handleSend()}
/>
@@ -593,6 +614,14 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
<PlatformLibraryModal
open={platformOpen}
imageOnly={mode === "keyframe"}
onClose={closePlatformLibrary}
onPick={handleLibraryPick}
notify={notify}
/>
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
+2 -1
View File
@@ -5,6 +5,7 @@ import { RefreshCw, Upload, User, X } from "lucide-react";
import { api } from "../api";
import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
@@ -351,7 +352,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
+316 -103
View File
@@ -12,13 +12,32 @@ import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
import {
allowedStructures,
clampDuration,
DURATION_OPTIONS,
durationWarning,
isForbidden,
PRESENTATION_FORMATS,
PRESENTATION_HINT,
PRESENTATION_KEYS,
recommendDuration,
recommendSetup,
SEGMENT_DURATION_MAX,
STRUCTURE_HINT,
TOTAL_DURATION_MIN,
VIDEO_STRUCTURES,
type PresentationFormat,
type VideoStructure,
} from "../script-setup";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
// 脚本来源 → 「来源」brief pill 文案
const SOURCE_LABEL: Record<string, string> = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" };
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
const VO_VOICES = [
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
@@ -28,9 +47,8 @@ const VO_VOICES = [
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
{ key: "BV002_streaming", label: "通用男声" },
];
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS)
// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示)
const WIZ_STYLE_LABEL: Record<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
@@ -570,6 +588,9 @@ export function PipelinePage(props: {
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
});
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
// 单镜时长自二期起是变长的(4–15s),缺值时按上限兜底,跟后端 script_agent 一致
const shotSeconds = (s: { duration_seconds?: number | null }) => s.duration_seconds || SEGMENT_DURATION_MAX;
const scriptTotalSeconds = shots.reduce((sum, s) => sum + shotSeconds(s), 0);
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白");
@@ -661,9 +682,9 @@ export function PipelinePage(props: {
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
const [triPanelOpen, setTriPanelOpen] = useState(false);
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层
// 通用「选已有素材替换」选图层。当前由场景卡使用(取 scene、团队全量);按商品过滤的 product_image 分支留给后续复用
// 商品三视图不走这里——它已改成卡片上的独立上传框 + AI 生成两条通道(见 uploadTriViewFromFile)
// 角色也不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的
type AssetPick = {
title: string;
category: "product_image" | "scene";
@@ -731,6 +752,8 @@ export function PipelinePage(props: {
setAssetPickBusy(false);
}
}
const triFileRef = useRef<HTMLInputElement | null>(null);
const [triUploading, setTriUploading] = useState(false);
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
@@ -1150,7 +1173,7 @@ export function PipelinePage(props: {
const timeline = project.timeline;
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || scriptTotalSeconds * 1000 || TOTAL_DURATION_MIN * 1000;
const ruler = buildRuler(tlRulerMs / 1000);
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
@@ -1234,24 +1257,72 @@ export function PipelinePage(props: {
const [chatText, setChatText] = useState("");
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
// 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」
const setupProduct = products.find((item) => item.id === project.product);
const recommended = useMemo(
() => recommendSetup({
category: setupProduct?.category,
targetAudience: setupProduct?.target_audience,
title: setupProduct?.title,
}),
[setupProduct?.category, setupProduct?.target_audience, setupProduct?.title]
);
const wizard = project.metadata?.wizard;
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");
// 向导选的成片时长档("0-30"→30 秒)传给脚本 agent —— 脚本按它定镜数,别再写死 60(否则选 30s 也按 60s 出)
const wizardTotalDuration = (() => {
const raw = project.metadata?.wizard?.duration;
const m = /(\d+)\s*$/.exec(typeof raw === "string" ? raw : "");
return m ? Number(m[1]) : 60;
})();
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
(wizard?.presentation_format as PresentationFormat) || recommended.format
);
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
(wizard?.video_structure as VideoStructure) || recommended.structure
);
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
const [setupDuration, setSetupDuration] = useState<number>(() => {
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
// 兼容一期向导存的字符串档位("0-30" → 30)
const legacy = /(\d+)\s*$/.exec(typeof wizard?.duration === "string" ? wizard.duration : "");
return legacy ? clampDuration(Number(legacy[1])) : recommended.duration;
});
// 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。
// 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。
const setupTouched = useRef(false);
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
useEffect(() => {
if (setupTouched.current || wizardHasCombo) return;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
function pickFormat(next: PresentationFormat) {
setupTouched.current = true;
setSetupFormat(next);
if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]);
}
const durationHint = durationWarning(setupStructure, setupDuration);
// 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒)
const durationSuggest = recommendDuration(setupFormat, setupStructure);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
// 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子,
// 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。
useEffect(() => {
const el = chatTextareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
}, [chatText]);
const chatFileRef = useRef<HTMLInputElement | null>(null);
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
const videoFileRef = useRef<HTMLInputElement | null>(null);
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
const chatBodyRef = useRef<HTMLDivElement | null>(null);
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
@@ -1432,8 +1503,12 @@ export function PipelinePage(props: {
model_config_id: activeScriptModelId || undefined,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
total_duration: wizardTotalDuration,
target_index: targetIndex
// 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒)
total_duration: setupDuration,
presentation_format: setupFormat,
video_structure: setupStructure,
target_index: targetIndex,
source: source === "manual" || source === "video" ? source : undefined
},
(evt) => {
receivedEvent = true;
@@ -1517,20 +1592,40 @@ export function PipelinePage(props: {
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
// 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
await onSaveProjectMeta?.({
wizard: {
...(project.metadata?.wizard ?? {}),
presentation_format: setupFormat,
video_structure: setupStructure,
total_duration: setupDuration,
persona: setupPersona,
},
});
setSetupOpen(false);
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
// 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成
setChatMode("theme");
pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`);
chatTextareaRef.current?.focus();
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
if (setupSource === "video") {
const base = chatText.trim();
if (!base) {
chatTextareaRef.current?.focus();
return;
}
setChatText("");
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
await runScriptGeneration(
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
`上传视频提炼 · ${combo}`,
"video",
);
return;
}
if (setupSource === "manual") {
@@ -1540,10 +1635,21 @@ export function PipelinePage(props: {
return;
}
setChatText("");
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
// 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍
await runScriptGeneration(
`${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;`
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`,
`上传脚本 · ${combo}`,
"manual",
);
return;
}
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
await runScriptGeneration(
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
`${sourceLabel}:${combo}`,
"ai",
);
}
// 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签
function tagEditsInstruction() {
@@ -1558,15 +1664,6 @@ export function PipelinePage(props: {
const text = chatText.trim();
const tagInstr = tagEditsInstruction();
if (!text && !tagInstr) return;
// 一句话主题:确定后模型已问主题,这次输入就是主题 → 带上已确认的风格/人物生成(仅首次出稿前)
if (chatMode === "theme" && !currentScript && text) {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setChatText("");
setChatAttachments([]);
void runScriptGeneration(`一句话主题:${text}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${text}`, "theme");
return;
}
const prompt = [tagInstr, text].filter(Boolean).join("\n");
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
setChatText("");
@@ -1594,40 +1691,86 @@ export function PipelinePage(props: {
setSetupOpen(false);
setChatMsgs([]);
}
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
function openSetup(source: "ai" | "theme" | "manual") {
// 选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
function openSetup(source: "ai" | "manual" | "video") {
setSetupSource(source);
setChatMode(source);
setSetupOpen(true);
// 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项);
// 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。
if (source === "manual") {
if (source === "manual" || source === "video") {
setChatText("");
setChatAttachments([]);
// 上传脚本 / 上传视频:选中即拉起选文件(用户手势内触发,不会被浏览器拦);想改成粘贴的话直接在输入框打字即可
(source === "video" ? videoFileRef : chatFileRef).current?.click();
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
}
// 文件上传入口先保留,当前「自带脚本」改为粘贴文本。
// if (source === "manual") { pickScriptMode(); }
}
function pickScriptMode() {
setChatMode("manual");
chatFileRef.current?.click();
}
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || "").trim();
if (!/\.(mp4|mov|m4v|webm)$/i.test(file.name)) {
onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
return;
}
setVideoDigestBusy(true);
pushMsg("user", `已上传视频《${file.name}`);
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
try {
const fd = new FormData();
fd.append("file", file);
const digest = await api.extractScriptVideo(project.id, fd);
const text = digest.text.trim();
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
setChatMode("video");
pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,`
+ "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。");
chatTextareaRef.current?.focus();
} catch (error) {
pushMsg("ai", "这条视频没能拆开,可以换一条再试。");
onNotify?.("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
setVideoDigestBusy(false);
}
}
// 上传脚本仅收 docx / txt。txt 前端直读;docx 交后端拆(zip+xml),前端不引解析库。
async function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const name = file.name.toLowerCase();
const isDocx = name.endsWith(".docx");
if (!isDocx && !name.endsWith(".txt")) {
onNotify?.("error", "只支持 docx 和 txt 两种脚本文件");
return;
}
setScriptFileBusy(true);
try {
let text = "";
if (isDocx) {
const fd = new FormData();
fd.append("file", file);
text = (await api.extractScriptFile(project.id, fd)).text.trim();
} else {
text = (await file.text()).trim();
}
if (!text) {
onNotify?.("error", "没能从这个文件里读到文字,请换一份");
return;
}
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
setChatMode("manual");
pushMsg("user", `已上传脚本附件${file.name}》(${text.length} 字)`);
pushMsg("ai", "已读入你的脚本。可在输入框补充修改意见,点发送据此生成镜头脚本。");
pushMsg("user", `已上传脚本《${file.name}》(${text.length} 字)`);
pushMsg("ai", "已读入你的脚本。确认风格与人物后点「确定」,我会保留你的原意,并把钩子、卖点证明、转化引导补齐。");
chatTextareaRef.current?.focus();
};
reader.readAsText(file);
} catch (error) {
onNotify?.("error", error instanceof Error && error.message ? error.message : "读取脚本文件失败,请重试");
} finally {
setScriptFileBusy(false);
}
}
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
const scriptGridRef = useRef<HTMLDivElement | null>(null);
@@ -2504,6 +2647,24 @@ export function PipelinePage(props: {
const productName = productRecord?.title || "透真补水面膜";
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
async function uploadTriViewFromFile(file: File) {
setTriUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("asset_type", "image");
fd.append("category", "tri_view");
fd.append("name", `${productName}·三视图`);
const asset = await api.uploadAsset(fd); // 落 TOS + 建团队资产
await onAttachBaseAsset({ kind: "product" }, asset.id); // 直接采用为商品三视图
} catch (e) {
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
} finally {
setTriUploading(false);
}
}
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
@@ -2632,10 +2793,23 @@ export function PipelinePage(props: {
</span>
</div>
<div className="script-brief-summary" aria-label="当前创作方向">
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),风格/人物=新建向导里选的(metadata.wizard) */}
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "AI 全生") : chatMode !== "ai" ? SOURCE_LABEL[chatMode] : "未选择"}</span></span>
<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>
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard */}
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{(() => {
const k = wizard?.presentation_format as PresentationFormat | undefined;
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{(() => {
const k = wizard?.video_structure as VideoStructure | undefined;
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-duration">{(() => {
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
return `${scripted || setupDuration}`;
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
</div>
{/* 34/3 · / :AI 稿;,,
线线, */}
@@ -2687,7 +2861,7 @@ export function PipelinePage(props: {
let cum = 0;
return shots.map((shot, index) => {
const start = cum;
cum += shot.duration_seconds || 15;
cum += shotSeconds(shot);
const narration = (shot.narration || "").trim();
const visual = (shot.visual_prompt || "").trim();
// 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段
@@ -2812,18 +2986,26 @@ export function PipelinePage(props: {
<div className="time">{msg.time}</div>
</div>
))}
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
{setupOpen && (
<div className="msg ai">
<div className="bubble setup-card">
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}</div>
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}</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>)}
<span className="sf-k"></span>
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
</select>
</label>
<div className="setup-rec"></div>
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)}
</select>
</label>
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
@@ -2831,15 +3013,25 @@ export function PipelinePage(props: {
</select>
</label>
<div className="setup-rec"></div>
{/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} </option>)}
</select>
</label>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot">
{/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */}
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}> </button>
<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 type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
// 重新推荐:回到按商品信息算出的那一组(不是随机换,随机换等于没推荐)
setupTouched.current = false;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
setSetupDuration(recommended.duration);
}}></button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading || (setupSource === "manual" && !chatText.trim())} onClick={() => void runScriptWithSetup()}></button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}></button>
</div>
</div>
<div className="time">{nowHm()}</div>
@@ -2851,9 +3043,10 @@ export function PipelinePage(props: {
<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={() => 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>
<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></button>
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} 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>
{/* 1.11 上传视频提炼:拆一条参考视频的分镜,改成自己的商品重拍 */}
<button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg></button>
</div>
</div>
)}
@@ -2878,7 +3071,7 @@ export function PipelinePage(props: {
</span>
))}
</div>
<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)}
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "manual" ? "上传 docx / txt,或直接把脚本粘进来 ·(Enter 发送)" : chatMode === "video" ? "拆解稿会填在这里,逐镜改完再点「确定」·(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) {
@@ -2886,15 +3079,20 @@ export function PipelinePage(props: {
submitChat();
}
}}></textarea>
{/* ,
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
*/}
<input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} />
<input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
<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 className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}>
{scriptFileBusy
? <span className="spinner" aria-hidden="true"></span>
: <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>
{/* 1.11 · 聊天已经开起来之后仍能提炼参考视频(入口菜单只在空态出现) */}
<button className="chat-icon-btn" id="chat-video-btn" type="button" title="上传视频提炼(mp4 / mov)" aria-label="上传视频提炼(mp4 / mov)" disabled={videoDigestBusy} onClick={() => videoFileRef.current?.click()}>
{videoDigestBusy
? <span className="spinner" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
</button>
*/}
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
{textModels && textModels.length > 0 ? (
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
@@ -3051,27 +3249,33 @@ export function PipelinePage(props: {
<div className="prod-date">{(project.created_at || "").slice(0, 10)} </div>
</div>
<div className="prod-action" id="asset-prod-action">
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
<div className="hstack">
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || assetPickBusy} onClick={() => setAssetPick({ title: "使用已有素材作三视图", category: "product_image", product: project.product, target: { kind: "product" }, uploadCategory: "tri_view", uploadName: `${productName || "商品"}·三视图` })}></button>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
</div>
</div>
</div>
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
{(() => {
const u = candUrl(productGroup, previewTriAsset);
if (triGenerating && !u) {
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono"></span></span></div>;
if ((triGenerating || triUploading) && !u) {
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">{triUploading ? "上传中" : "生成中"}</span></span></div>;
}
// 空态即上传框:点一下直接选本地图当三视图(不占用右侧 AI 生成那条路)
if (!u) {
return (
<div className="placeholder prod-preview-img prod-tri-drop" id="prod-preview-img" role="button" tabIndex={0} title="上传本地图片作三视图" onClick={() => triFileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); triFileRef.current?.click(); } }}>
<span className="ph-frame">+ </span>
</div>
);
}
return (
<div className={`placeholder prod-preview-img${u ? " has-mock-media is-zoomable" : ""}`} id="prod-preview-img" role={u ? "button" : undefined} tabIndex={u ? 0 : undefined} title={u ? "点击放大查看" : undefined} style={u ? mediaStyle(u) : undefined} onClick={u ? () => setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}>
{!u && <span className="ph-frame">// 暂无三视图</span>}
</div>
<div className="placeholder prod-preview-img has-mock-media is-zoomable" id="prod-preview-img" role="button" tabIndex={0} title="点击放大查看" style={mediaStyle(u)} onClick={() => setPreview({ src: u, kind: "image", name: "三视图" })} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } }} />
);
})()}
{previewTriAsset && (
@@ -3302,7 +3506,7 @@ export function PipelinePage(props: {
{viewStage === 3 && (() => {
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
let cum = 0;
const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; });
const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); return `${st}~${cum}s`; });
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
const cardCount = sbShots.length || sbExpectedShots;
@@ -3471,6 +3675,15 @@ export function PipelinePage(props: {
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
const segMax = segSeconds.length ? Math.max(...segSeconds) : 0;
const segTotal = segSeconds.reduce((sum, n) => sum + n, 0);
const paceText = !segSeconds.length
? "每场按脚本时长出片"
: segMin === segMax
? `每场 Seedance ${segMin} 秒 · 全片 ${segTotal}`
: `每场 Seedance ${segMin}${segMax} 秒 · 全片 ${segTotal}`;
const statusText = !segments.length
? "暂无片段"
: segDone === segments.length
@@ -3483,7 +3696,7 @@ export function PipelinePage(props: {
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · {segDone} / {segments.length} </div>
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 15 秒 · {statusText}</div>
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// {paceText} · {statusText}</div>
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import { SystemLoading } from "../components/loading";
import { ConfirmModal } from "../components/overlays";
import type { NavigateFn } from "./route-config";
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
@@ -326,7 +327,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
) : isEmpty ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
) : (