大量修改二期功能清单内容
This commit is contained in:
+22
-38
@@ -21,6 +21,7 @@ import type {
|
||||
import { publicModelDisplayName } from "./model-display";
|
||||
import { generationErrorText } from "./generation-error";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
|
||||
import { SystemLoading } from "./components/loading";
|
||||
import {
|
||||
AccountPage,
|
||||
AssetFactoryPage,
|
||||
@@ -769,20 +770,12 @@ export function App() {
|
||||
|
||||
if (booting || !user || !team) {
|
||||
return (
|
||||
<div className="app">
|
||||
<main>
|
||||
<div className="content">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>正在进入工作台…</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// 正在拉取团队数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SystemLoading
|
||||
variant="fullscreen"
|
||||
title="正在进入工作台"
|
||||
description="正在同步账号与团队数据,请稍候。"
|
||||
icon="layoutDashboard"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -985,31 +978,22 @@ export function App() {
|
||||
// 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。
|
||||
// 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。
|
||||
if (page === "pipeline" && activeProjectId && !pipelineProject) {
|
||||
const projectRef = activeProjectId.slice(0, 8).toUpperCase();
|
||||
return (
|
||||
<div className="app">
|
||||
<main>
|
||||
<div className="content">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{projectDetailError ? "项目加载失败" : "加载中…"}</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">{projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{projectDetailError && (
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>
|
||||
重试
|
||||
</button>
|
||||
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}>
|
||||
返回项目列表
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SystemLoading
|
||||
variant="fullscreen"
|
||||
state={projectDetailError ? "error" : "loading"}
|
||||
title={projectDetailError ? "项目加载失败" : "正在打开项目工作台"}
|
||||
description={projectDetailError ? "暂时无法连接服务,请重试或返回项目列表。" : "正在同步项目数据,请稍候。"}
|
||||
icon={projectDetailError ? "activity" : "clapperboard"}
|
||||
reference={projectRef}
|
||||
actions={projectDetailError ? (
|
||||
<>
|
||||
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>重试</button>
|
||||
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}>返回项目列表</button>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (page === "pipeline" && pipelineProject) {
|
||||
|
||||
@@ -198,6 +198,20 @@
|
||||
|
||||
/* ── 资产审核队列 ── */
|
||||
.admin-table .col-check { width: 36px; text-align: center; }
|
||||
.admin-thumb-btn {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
border-radius: var(--r-md);
|
||||
line-height: 0;
|
||||
}
|
||||
.admin-thumb-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
.admin-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -206,6 +220,7 @@
|
||||
background: var(--background-lighter);
|
||||
border: 1px solid var(--border-faint);
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.admin-thumb-empty { display: inline-block; }
|
||||
.admin-review-err {
|
||||
@@ -220,14 +235,14 @@
|
||||
font-size: 11px;
|
||||
cursor: help;
|
||||
}
|
||||
/* 收窄到 .admin-app 作用域:`.bulk-bar` 是全局裸类(商品库/项目/资产库各有黑底版),
|
||||
不限定会随 main.tsx 末位加载盖掉那些黑底浮条 → 全站批量栏被染白。 */
|
||||
.admin-app .bulk-bar {
|
||||
/* 独立类名,避开商品库/项目页全局黑底 `.bulk-bar button`(白字透明边),
|
||||
否则管理台白底浮条上按钮文字不可见,只剩一条扁边框。 */
|
||||
.admin-bulk-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 28px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
@@ -237,7 +252,12 @@
|
||||
box-shadow: var(--shadow-floating);
|
||||
z-index: 50;
|
||||
}
|
||||
.admin-app .bulk-count { font-size: 13px; color: var(--accent-black); font-weight: 500; }
|
||||
.admin-bulk-count {
|
||||
font-size: 13px;
|
||||
color: var(--accent-black);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 任务监控 ── */
|
||||
.admin-anomaly-chip { height: 28px; padding: 0 14px; font-size: 12.5px; cursor: pointer; }
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
UserPreference,
|
||||
VoiceoverInfo
|
||||
} from "./types";
|
||||
import type { PresentationFormat, VideoStructure } from "./script-setup";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const TOKEN_KEY = "airshelf_token";
|
||||
@@ -326,6 +327,21 @@ export const api = {
|
||||
purgeProject(id: string) {
|
||||
return request<void>(`/api/projects/${id}/purge/`, { method: "DELETE" });
|
||||
},
|
||||
// 上传脚本(docx)取正文:docx 是 zip,交后端拆,前端不引解析库。txt 前端自己 file.text() 即可。
|
||||
extractScriptFile(projectId: string, formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string }>(
|
||||
`/api/projects/${projectId}/extract-script-file/`,
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
// 1.11 上传视频提炼:后端抽帧 → 多模态读帧 → 中文分镜稿。与上传脚本同形状(只回文本不落库),
|
||||
// 差别是真调模型,慢(整条 30~60 秒)且计一次费,故前端要给等待反馈。
|
||||
extractScriptVideo(projectId: string, formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number }>(
|
||||
`/api/projects/${projectId}/extract-script-video/`,
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
|
||||
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||
async agentScriptStream(
|
||||
@@ -338,7 +354,12 @@ export const api = {
|
||||
base_version_id?: string;
|
||||
aspect_ratio?: string;
|
||||
total_duration?: number;
|
||||
// 二期:表现形式 × 视频结构 —— 后端按这两个挑套路提示词,不传则回落 口播 × 痛点解决
|
||||
presentation_format?: PresentationFormat;
|
||||
video_structure?: VideoStructure;
|
||||
target_index?: number;
|
||||
// 哪个入口来的:辅助生成 / 上传脚本 / 上传视频提炼。三者都是 mode=auto,靠它区分并记进脚本「来源」
|
||||
source?: "ai" | "manual" | "video";
|
||||
},
|
||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
|
||||
signal?: AbortSignal
|
||||
@@ -542,9 +563,13 @@ export const api = {
|
||||
videoPacks() {
|
||||
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
|
||||
},
|
||||
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)
|
||||
submitAssetReview(id: string) {
|
||||
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
|
||||
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)。
|
||||
// forReference:素材要拿去当生成参考(自由创作 @引用三库),此时不看类目白名单一律送审。
|
||||
submitAssetReview(id: string, forReference = false) {
|
||||
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ for_reference: forReference })
|
||||
});
|
||||
},
|
||||
// 置 in_library(功能性模特/角色资料不走该入口;图片成图仍按原规则入库)
|
||||
setAssetLibrary(id: string, inLibrary: boolean) {
|
||||
|
||||
@@ -59,7 +59,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onClear, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
@@ -81,6 +81,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
onDurationChange: (duration: number) => void;
|
||||
onSeedChange: (seed: number) => void;
|
||||
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
@@ -134,6 +135,9 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
|
||||
<IconKitSvg name="users" size={18} />
|
||||
</button>
|
||||
<button type="button" className="fc-add fc-lib" title="引用平台素材(资产库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
|
||||
<IconKitSvg name="library" size={18} />
|
||||
</button>
|
||||
{refs.map((item) => (
|
||||
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// 自由创作·三库引用弹窗(模块4 · 4.1/4.3):资产库 / 模特库 / 商品库 三个 tab 挑素材。
|
||||
// 三个库挑出来的最终都是一行平台 Asset,所以注入输入条时统一 source=asset,
|
||||
// 后端 build_content_items 只有一个分支要处理,审核闸也只有一处。
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, Images, Package, Search, ShieldCheck, User, X } from "lucide-react";
|
||||
import { api } from "../../api";
|
||||
import type { Asset, FreeVideoRef, ModelEntity, Product } from "../../types";
|
||||
import { useBodyScrollLock } from "../overlays";
|
||||
|
||||
type LibTab = "assets" | "models" | "products";
|
||||
|
||||
const TAB_LABEL: Record<LibTab, string> = { assets: "资产库", models: "模特库", products: "商品库" };
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
/** 引用前的审核判定。与后端 apps/assets/review.py 的 reference_review_state 同一套规则:
|
||||
* 平台生成的免审,用户上传的必须审过。这里只为提前给个说法,真正拦人的是后端。 */
|
||||
type RefState = "allowed" | "processing" | "failed" | "unsubmitted";
|
||||
|
||||
function refState(asset: Pick<Asset, "source" | "review_status">): RefState {
|
||||
if (asset.source === "ai_generated" || asset.source === "system") return "allowed";
|
||||
if (asset.review_status === "active") return "allowed";
|
||||
if (asset.review_status === "processing") return "processing";
|
||||
if (asset.review_status === "failed") return "failed";
|
||||
return "unsubmitted";
|
||||
}
|
||||
|
||||
const STATE_PILL: Record<Exclude<RefState, "allowed">, { cls: string; label: string }> = {
|
||||
processing: { cls: "pill-info", label: "审核中" },
|
||||
failed: { cls: "pill-err", label: "未通过" },
|
||||
unsubmitted: { cls: "pill-warn", label: "待送审" }
|
||||
};
|
||||
|
||||
function assetThumb(asset: Asset): string {
|
||||
const primary = asset.files?.find((f) => f.is_primary) || asset.files?.[0];
|
||||
return primary?.preview_url || "";
|
||||
}
|
||||
|
||||
export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify }: {
|
||||
open: boolean;
|
||||
/** 首尾帧模式只能用图片 */
|
||||
imageOnly: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (ref: FreeVideoRef) => void;
|
||||
notify: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<LibTab>("assets");
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [models, setModels] = useState<ModelEntity[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
// 商品库是两层的:先选商品,再从它的图里挑一张
|
||||
const [openProduct, setOpenProduct] = useState<Product | null>(null);
|
||||
const [productAssets, setProductAssets] = useState<Asset[]>([]);
|
||||
const [submittingReview, setSubmittingReview] = useState<string | null>(null);
|
||||
useBodyScrollLock(open);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setAssets([]);
|
||||
setModels([]);
|
||||
setProducts([]);
|
||||
setOpenProduct(null);
|
||||
setProductAssets([]);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTab("assets");
|
||||
setQueryInput("");
|
||||
setQuery("");
|
||||
resetView();
|
||||
}, [open, resetView]);
|
||||
|
||||
// 搜索框防抖:不防抖会一个字一次请求,列表还会边打边跳
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setQuery(queryInput.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [queryInput]);
|
||||
|
||||
const loadAssets = useCallback(async (nextPage: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.assetsPage({
|
||||
q: query || undefined,
|
||||
asset_type: imageOnly ? "image" : undefined,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE
|
||||
});
|
||||
setAssets((prev) => (nextPage === 1 ? data.results : [...prev, ...data.results]));
|
||||
setPage(nextPage);
|
||||
setHasMore(Boolean(data.next));
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "资产加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query, imageOnly, notify]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listModels({ q: query || undefined });
|
||||
setModels(data.results);
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "模特库加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query, notify]);
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.products();
|
||||
const keyword = query.trim().toLowerCase();
|
||||
setProducts(keyword
|
||||
? data.results.filter((p) => `${p.title} ${p.brand} ${p.category}`.toLowerCase().includes(keyword))
|
||||
: data.results);
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "商品库加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
resetView();
|
||||
if (tab === "assets") void loadAssets(1);
|
||||
else if (tab === "models") void loadModels();
|
||||
else void loadProducts();
|
||||
// query 变化也要重拉,所以它在依赖里
|
||||
}, [open, tab, loadAssets, loadModels, loadProducts, resetView]);
|
||||
|
||||
// 商品下钻:把它的封面图 + 素材图取成一组资产
|
||||
const openProductDetail = useCallback(async (product: Product) => {
|
||||
setOpenProduct(product);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.assetsPage({ product: product.id, asset_type: imageOnly ? "image" : undefined, pageSize: 100 });
|
||||
setProductAssets(data.results);
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "商品素材加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageOnly, notify]);
|
||||
|
||||
const pickAsset = useCallback((asset: Asset, labelHint?: string) => {
|
||||
const state = refState(asset);
|
||||
if (state === "processing") { notify("info", "这个素材还在审核中,通过后才能引用"); return; }
|
||||
if (state === "failed") { notify("error", asset.review_error || "这个素材未通过审核,不能引用"); return; }
|
||||
if (state === "unsubmitted") { notify("info", "上传素材要先审核通过才能引用,点卡片上的「送审」提交"); return; }
|
||||
const kind = asset.asset_type === "video" ? "video" : asset.asset_type === "audio" ? "audio" : "image";
|
||||
if (imageOnly && kind !== "image") { notify("error", "首尾帧仅支持图片素材"); return; }
|
||||
const url = assetThumb(asset);
|
||||
if (!url) { notify("error", "这个素材没有可用文件"); return; }
|
||||
onPick({
|
||||
url,
|
||||
type: kind,
|
||||
label: (labelHint || asset.display_name || asset.name || "素材").slice(0, 24),
|
||||
thumb_url: kind === "image" ? url : undefined,
|
||||
asset_id: asset.id,
|
||||
source: "asset"
|
||||
});
|
||||
onClose();
|
||||
}, [imageOnly, onPick, onClose, notify]);
|
||||
|
||||
const sendToReview = useCallback(async (asset: Asset) => {
|
||||
setSubmittingReview(asset.id);
|
||||
try {
|
||||
const data = await api.submitAssetReview(asset.id, true);
|
||||
const patch = (list: Asset[]) => list.map((a) => (a.id === asset.id ? { ...a, review_status: data.review_status } : a));
|
||||
setAssets(patch);
|
||||
setProductAssets(patch);
|
||||
notify("success", "已提交审核,通过后即可引用");
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "送审失败");
|
||||
} finally {
|
||||
setSubmittingReview(null);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// 模特一张卡最多两个可引用资产:形象图 + 三视图
|
||||
const modelAssets = useCallback((model: ModelEntity) => {
|
||||
const out: Array<{ id: string; url: string; tag: string }> = [];
|
||||
if (model.portrait_asset && model.portrait) out.push({ id: model.portrait_asset, url: model.portrait, tag: "形象图" });
|
||||
if (model.triview_asset && model.triview) out.push({ id: model.triview_asset, url: model.triview, tag: "三视图" });
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// 模特库的资产是平台生成/上传的 Model 附属资产,拿不到完整 Asset 行 → 交给后端判审核,
|
||||
// 这里直接按 id 引用,后端 _guard_asset_reference 会给出准确的放行或拒绝理由。
|
||||
const pickModelAsset = useCallback((model: ModelEntity, item: { id: string; url: string; tag: string }) => {
|
||||
onPick({
|
||||
url: item.url,
|
||||
type: "image",
|
||||
label: `${model.name}${item.tag === "三视图" ? "三视图" : ""}`.slice(0, 24),
|
||||
thumb_url: item.url,
|
||||
asset_id: item.id,
|
||||
source: "asset"
|
||||
});
|
||||
onClose();
|
||||
}, [onPick, onClose]);
|
||||
|
||||
const gridAssets = openProduct ? productAssets : assets;
|
||||
const emptyText = useMemo(() => {
|
||||
if (tab === "models") return "还没有模特";
|
||||
if (tab === "products") return openProduct ? "这个商品还没有素材" : "还没有商品";
|
||||
return "资产库里还没有可引用的素材";
|
||||
}, [tab, openProduct]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderAssetCard = (asset: Asset, labelHint?: string) => {
|
||||
const state = refState(asset);
|
||||
const pill = state === "allowed" ? null : STATE_PILL[state];
|
||||
const thumb = assetThumb(asset);
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`fc-lib-asset${state === "allowed" ? " pickable" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => pickAsset(asset, labelHint)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") pickAsset(asset, labelHint); }}
|
||||
>
|
||||
<div className="fc-lib-thumb">
|
||||
{thumb && asset.asset_type === "image"
|
||||
? <img src={thumb} alt={asset.name} />
|
||||
: <span className="mono">{asset.asset_type === "audio" ? "♪" : asset.asset_type.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="fc-lib-name" title={asset.display_name || asset.name}>{asset.display_name || asset.name}</div>
|
||||
{pill
|
||||
? <span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||
: <span className="fc-lib-count mono">// {asset.source === "upload" ? "已过审" : "平台生成"}</span>}
|
||||
{state === "unsubmitted" && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost fc-lib-review-btn"
|
||||
disabled={submittingReview === asset.id}
|
||||
onClick={(event) => { event.stopPropagation(); void sendToReview(asset); }}
|
||||
>
|
||||
<ShieldCheck size={12} /> {submittingReview === asset.id ? "提交中…" : "送审"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-bg show" onClick={onClose}>
|
||||
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Images size={16} /></div>
|
||||
<div className="ti">
|
||||
{openProduct ? (
|
||||
<button type="button" className="fc-lib-back" onClick={() => { setOpenProduct(null); setProductAssets([]); }}>
|
||||
<ChevronLeft size={14} /> {openProduct.title}
|
||||
</button>
|
||||
) : "引用平台素材"}
|
||||
<span>// 资产库 · 模特库 · 商品库</span>
|
||||
</div>
|
||||
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b fc-lib-body">
|
||||
{!openProduct && (
|
||||
<div className="tabs fc-lib-tabs">
|
||||
{(Object.keys(TAB_LABEL) as LibTab[]).map((key) => (
|
||||
<div key={key} className={`tab${tab === key ? " active" : ""}`} role="button" tabIndex={0}
|
||||
onClick={() => setTab(key)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") setTab(key); }}>
|
||||
{TAB_LABEL[key]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="fc-lib-toolbar">
|
||||
{/* 商品下钻后列的是这一个商品的素材,搜索框在这层没有意义,收起来免得成死控件 */}
|
||||
{!openProduct && (
|
||||
<label className="fc-lib-search">
|
||||
<Search size={13} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={`搜索${TAB_LABEL[tab]}…`}
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="mono fc-lib-hint">
|
||||
{imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tab === "models" && !openProduct ? (
|
||||
models.length === 0 && !loading ? (
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><User size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 去模特库添加模特后可在这里引用形象图与三视图</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
{models.map((model) => {
|
||||
const items = modelAssets(model);
|
||||
return (
|
||||
<div key={model.id} className="fc-lib-asset">
|
||||
<div className="fc-lib-thumb">
|
||||
{model.portrait ? <img src={model.portrait} alt={model.name} /> : <User size={20} />}
|
||||
</div>
|
||||
<div className="fc-lib-name" title={model.name}>{model.name}</div>
|
||||
<div className="fc-lib-model-picks">
|
||||
{items.length === 0
|
||||
? <span className="fc-lib-count mono">// 还没有可引用的图</span>
|
||||
: items.map((item) => (
|
||||
<button key={item.id} type="button" className="btn btn-sm" onClick={() => pickModelAsset(model, item)}>
|
||||
{item.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : tab === "products" && !openProduct ? (
|
||||
products.length === 0 && !loading ? (
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Package size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 去商品库建商品后可在这里引用它的素材</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="fc-lib-asset pickable" role="button" tabIndex={0}
|
||||
onClick={() => void openProductDetail(product)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") void openProductDetail(product); }}>
|
||||
<div className="fc-lib-thumb">
|
||||
{product.cover_preview_url ? <img src={product.cover_preview_url} alt={product.title} /> : <Package size={20} />}
|
||||
</div>
|
||||
<div className="fc-lib-name" title={product.title}>{product.title}</div>
|
||||
<div className="fc-lib-count mono">// {product.category || "未分类"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : gridAssets.length === 0 && !loading ? (
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Images size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 平台生成的素材可直接引用,上传的要先审核通过</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="fc-lib-assets">
|
||||
{gridAssets.map((asset) => renderAssetCard(asset, openProduct ? `${openProduct.title}` : undefined))}
|
||||
</div>
|
||||
{!openProduct && tab === "assets" && hasMore && (
|
||||
<div className="fc-lib-more">
|
||||
<button type="button" className="btn btn-sm" disabled={loading} onClick={() => void loadAssets(page + 1)}>
|
||||
{loading ? "加载中…" : "加载更多"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -25,3 +25,141 @@
|
||||
color: var(--black-alpha-56, #8a8a8a);
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
/* 全局加载面板:用于整页、主要区块与弹窗内容首次加载。 */
|
||||
.system-loading {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.system-loading--fullscreen { min-height: 100vh; padding: 48px 24px; }
|
||||
.system-loading--page { min-height: min(480px, 62vh); }
|
||||
.system-loading--inline { min-height: 200px; padding: 24px 16px; }
|
||||
.system-loading-card {
|
||||
position: relative;
|
||||
width: min(440px, 100%);
|
||||
padding: 28px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
}
|
||||
.system-loading--inline .system-loading-card { width: min(400px, 100%); padding: 24px; }
|
||||
.system-loading-corner {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-24);
|
||||
background: var(--background-base);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
.system-loading-corner.corner-tl { top: -7px; left: -7px; }
|
||||
.system-loading-corner.corner-tr { top: -7px; right: -7px; }
|
||||
.system-loading-corner.corner-bl { bottom: -7px; left: -7px; }
|
||||
.system-loading-corner.corner-br { right: -7px; bottom: -7px; }
|
||||
.system-loading-meta,
|
||||
.system-loading-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.system-loading-meta span:last-child { color: var(--heat); }
|
||||
.system-loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 36px 0 30px;
|
||||
}
|
||||
.system-loading--inline .system-loading-main { padding: 28px 0 24px; }
|
||||
.system-loading-icon {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.system-loading-orbit {
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border: 1px solid var(--heat-20);
|
||||
border-top-color: var(--heat);
|
||||
border-radius: var(--r-md);
|
||||
animation: system-loading-spin 1.1s linear infinite;
|
||||
}
|
||||
.system-loading-copy { min-width: 0; }
|
||||
.system-loading-copy h2 {
|
||||
margin: 0;
|
||||
color: var(--accent-black);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.01em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.system-loading-copy p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.system-loading-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
height: 3px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.system-loading-progress span {
|
||||
border-radius: 2px;
|
||||
background: var(--black-alpha-8);
|
||||
animation: system-loading-step 1.5s ease-in-out infinite;
|
||||
}
|
||||
.system-loading-progress span:nth-child(2) { animation-delay: .12s; }
|
||||
.system-loading-progress span:nth-child(3) { animation-delay: .24s; }
|
||||
.system-loading-progress span:nth-child(4) { animation-delay: .36s; }
|
||||
.system-loading-progress span:nth-child(5) { animation-delay: .48s; }
|
||||
.system-loading-foot {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
.system-loading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.system-loading.is-error .system-loading-meta span:last-child,
|
||||
.system-loading.is-error .system-loading-icon { color: var(--accent-crimson); }
|
||||
.system-loading.is-error .system-loading-icon { background: var(--crimson-bg); }
|
||||
|
||||
@keyframes system-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes system-loading-step {
|
||||
0%, 70%, 100% { background: var(--black-alpha-8); }
|
||||
28% { background: var(--heat); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.system-loading-orbit,
|
||||
.system-loading-progress span { animation: none; }
|
||||
.system-loading-progress span:first-child { background: var(--heat); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.system-loading-card { padding: 24px; }
|
||||
.system-loading-meta span:first-child,
|
||||
.system-loading-foot span:last-child { display: none; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { IconKitSvg } from "./IconKitSvg";
|
||||
import "./loading.css";
|
||||
|
||||
export function SystemLoading({
|
||||
variant = "page",
|
||||
state = "loading",
|
||||
title = "正在加载",
|
||||
description = "正在同步最新数据,请稍候。",
|
||||
icon = "activity",
|
||||
reference,
|
||||
actions,
|
||||
}: {
|
||||
variant?: "inline" | "page" | "fullscreen";
|
||||
state?: "loading" | "error";
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
reference?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`system-loading system-loading--${variant}${state === "error" ? " is-error" : ""}`} role={state === "error" ? "alert" : "status"} aria-live="polite">
|
||||
<div className="system-loading-card">
|
||||
<span className="system-loading-corner corner-tl" aria-hidden="true">+</span>
|
||||
<span className="system-loading-corner corner-tr" aria-hidden="true">+</span>
|
||||
<span className="system-loading-corner corner-bl" aria-hidden="true">+</span>
|
||||
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
|
||||
|
||||
<div className="system-loading-meta mono">
|
||||
<span>[ AIRSHELF / SYSTEM ]</span>
|
||||
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
|
||||
</div>
|
||||
|
||||
<div className="system-loading-main">
|
||||
<div className="system-loading-icon" aria-hidden="true">
|
||||
<IconKitSvg name={icon} size={20} />
|
||||
{state === "loading" && <span className="system-loading-orbit" />}
|
||||
</div>
|
||||
<div className="system-loading-copy">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? (
|
||||
<div className="system-loading-actions">{actions}</div>
|
||||
) : state === "loading" ? (
|
||||
<div className="system-loading-progress" aria-hidden="true">
|
||||
<span /><span /><span /><span /><span />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="system-loading-foot mono">
|
||||
<span>{reference ? `// REF · ${reference}` : "// DATA SYNC"}</span>
|
||||
<span>{state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。
|
||||
export function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
|
||||
@@ -605,6 +605,11 @@
|
||||
color: var(--accent-crimson);
|
||||
border-color: var(--crimson-bd);
|
||||
}
|
||||
.fc-lib-status.pill-warn {
|
||||
background: var(--honey-bg);
|
||||
color: var(--accent-honey);
|
||||
border-color: var(--honey-bd);
|
||||
}
|
||||
.fc-lib-status .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
@@ -678,6 +683,16 @@
|
||||
background: var(--crimson-bg);
|
||||
}
|
||||
|
||||
/* —— 三库引用弹窗(资产库 / 模特库 / 商品库)—— */
|
||||
.fc-lib-tabs { margin-bottom: 14px; }
|
||||
.fc-lib-search { position: relative; display: flex; align-items: center; flex: 1; max-width: 280px; }
|
||||
.fc-lib-search svg { position: absolute; left: 10px; z-index: 2; color: var(--black-alpha-48); }
|
||||
.fc-lib-search .input { height: 30px; width: 100%; padding-left: 30px; }
|
||||
.fc-lib-review-btn { height: 24px; padding: 0 8px; font-size: 11.5px; align-self: flex-start; }
|
||||
.fc-lib-model-picks { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; }
|
||||
.fc-lib-more { display: flex; justify-content: center; padding-top: 14px; }
|
||||
|
||||
/* 移动端:输入条参数换行、任务流单列 */
|
||||
@media (max-width: 720px) {
|
||||
.fc-feed { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -139,6 +139,8 @@
|
||||
.chat-input-foot .spacer { flex: 1; }
|
||||
.chat-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border-faint); border-radius: 50%; color: var(--ink-3); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
|
||||
.chat-icon-btn:hover { border-color: var(--accent-black); color: var(--accent-black); }
|
||||
.chat-icon-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.chat-icon-btn:disabled:hover { border-color: var(--border-faint); color: var(--ink-3); }
|
||||
.chat-send-btn { width: 32px; height: 32px; display: grid; place-items: center; background: var(--accent-black); border: 1px solid var(--accent-black); border-radius: 50%; color: var(--accent-white); cursor: pointer; transition: background var(--t-base), border-color var(--t-base), transform var(--t-base); }
|
||||
.chat-send-btn:hover { background: var(--heat); border-color: var(--heat); }
|
||||
.chat-send-btn:active { transform: scale(.95); }
|
||||
@@ -313,6 +315,10 @@
|
||||
.prod-preview-history .h-thumb.adopted .badge { display: block; }
|
||||
.prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); }
|
||||
.prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }
|
||||
/* 三视图空态 = 上传框:虚线示意可投放,hover 转橙提示可点 */
|
||||
.prod-tri-drop { cursor: pointer; border-style: dashed; transition: border-color var(--t-base); }
|
||||
.prod-tri-drop:hover { border-color: var(--heat-40); }
|
||||
.prod-tri-drop:hover .ph-frame { color: var(--heat); border-color: var(--heat-20); }
|
||||
.prod-preview-adopt:disabled, .prod-preview-adopt:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); cursor: not-allowed; opacity: 1; }
|
||||
|
||||
.asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }
|
||||
@@ -612,14 +618,17 @@
|
||||
.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 · 生成前「来源 & 风格 & 人物」设定卡 ── */
|
||||
/* ── 1.5 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定卡 ── */
|
||||
/* 标签列 64px:装得下最长的「表现形式」四个字(12px mono ≈ 52px)且四行对齐 */
|
||||
.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 { display: grid; grid-template-columns: 64px 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; }
|
||||
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */
|
||||
.setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; }
|
||||
.setup-card .setup-rec.warn { color: var(--accent-honey); }
|
||||
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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="删除视频"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/** 脚本设定卡的选项体系:表现形式 × 视频结构 × 时长(二期第2段)。
|
||||
*
|
||||
* 这里的枚举、禁用组合、时长区间必须跟后端 apps/ai/script_agent.py 的常量保持一致 ——
|
||||
* 后端会 coerce 兜底,但前端先挡住能避免用户选了一个又被悄悄改掉。
|
||||
*/
|
||||
|
||||
export type PresentationFormat = "oral" | "drama" | "vlog";
|
||||
export type VideoStructure = "pain" | "contrast" | "review" | "scene";
|
||||
|
||||
export const PRESENTATION_FORMATS: Record<PresentationFormat, string> = {
|
||||
oral: "口播",
|
||||
drama: "短剧",
|
||||
vlog: "Vlog",
|
||||
};
|
||||
|
||||
export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
|
||||
pain: "痛点解决",
|
||||
contrast: "前后对比",
|
||||
review: "测评验证",
|
||||
scene: "场景种草",
|
||||
};
|
||||
|
||||
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
|
||||
export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[];
|
||||
|
||||
/** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */
|
||||
export const PRESENTATION_HINT: Record<PresentationFormat, string> = {
|
||||
oral: "一个人对镜头说,信息密度最高、最省制作",
|
||||
drama: "有角色有冲突的微型剧,完播率最高",
|
||||
vlog: "第一人称生活记录,广告感最低",
|
||||
};
|
||||
|
||||
export const STRUCTURE_HINT: Record<VideoStructure, string> = {
|
||||
pain: "先戳痛点再给解法,适用面最广",
|
||||
contrast: "用前后差距说话,视觉冲击最强",
|
||||
review: "当场举证验证,适合高客单价",
|
||||
scene: "把商品放进向往的场景里种草",
|
||||
};
|
||||
|
||||
/** 唯一禁用组合:短剧 × 测评验证 —— 演出来的实测没有可信度。 */
|
||||
const FORBIDDEN: ReadonlyArray<[PresentationFormat, VideoStructure]> = [["drama", "review"]];
|
||||
|
||||
export function isForbidden(format: PresentationFormat, structure: VideoStructure): boolean {
|
||||
return FORBIDDEN.some(([f, s]) => f === format && s === structure);
|
||||
}
|
||||
|
||||
/** 1.8 组合联动:选定表现形式后,视频结构只剩这些可选。 */
|
||||
export function allowedStructures(format: PresentationFormat): VideoStructure[] {
|
||||
return STRUCTURE_KEYS.filter((key) => !isForbidden(format, key));
|
||||
}
|
||||
|
||||
// ── 时长:总时长 5–60 秒、5 秒步进;单镜 4–15 秒由后端切,前端只管总时长 ──
|
||||
export const SEGMENT_DURATION_MIN = 4;
|
||||
export const SEGMENT_DURATION_MAX = 15;
|
||||
export const TOTAL_DURATION_MIN = 5;
|
||||
export const TOTAL_DURATION_MAX = 60;
|
||||
export const TOTAL_DURATION_STEP = 5;
|
||||
export const DURATION_OPTIONS: number[] = Array.from(
|
||||
{ length: (TOTAL_DURATION_MAX - TOTAL_DURATION_MIN) / TOTAL_DURATION_STEP + 1 },
|
||||
(_, i) => TOTAL_DURATION_MIN + i * TOTAL_DURATION_STEP
|
||||
);
|
||||
|
||||
/** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */
|
||||
const FORMAT_DEFAULT_DURATION: Record<PresentationFormat, number> = { oral: 30, drama: 45, vlog: 30 };
|
||||
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。 */
|
||||
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 10, review: 20, scene: 20 };
|
||||
|
||||
export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number {
|
||||
return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]);
|
||||
}
|
||||
|
||||
export function clampDuration(seconds: number): number {
|
||||
if (!Number.isFinite(seconds)) return FORMAT_DEFAULT_DURATION.oral;
|
||||
const stepped = Math.round(seconds / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP;
|
||||
return Math.min(TOTAL_DURATION_MAX, Math.max(TOTAL_DURATION_MIN, stepped));
|
||||
}
|
||||
|
||||
/** 时长低于该结构的最短可用值时给一句提醒(不拦,只提示)。 */
|
||||
export function durationWarning(structure: VideoStructure, seconds: number): string {
|
||||
const min = STRUCTURE_MIN_DURATION[structure];
|
||||
if (seconds >= min) return "";
|
||||
return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`;
|
||||
}
|
||||
|
||||
// ── 2.3 按商品信息推荐默认组合 ──
|
||||
|
||||
type Combo = { format: PresentationFormat; structure: VideoStructure };
|
||||
|
||||
/** 品类关键词 → 推荐组合。命中第一条即用,没命中走兜底。 */
|
||||
const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [
|
||||
{ keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: { format: "oral", structure: "contrast" } },
|
||||
{ keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review" } },
|
||||
{ keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具"], combo: { format: "oral", structure: "review" } },
|
||||
{ keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: { format: "vlog", structure: "scene" } },
|
||||
{ keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: { format: "vlog", structure: "scene" } },
|
||||
{ keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: { format: "drama", structure: "pain" } },
|
||||
{ keywords: ["母婴", "宝宝", "儿童", "宠物"], combo: { format: "drama", structure: "pain" } },
|
||||
{ keywords: ["户外", "露营", "运动", "健身"], combo: { format: "vlog", structure: "scene" } },
|
||||
];
|
||||
|
||||
const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain" };
|
||||
|
||||
/** 按商品品类 + 人群推荐一组「表现形式 × 视频结构 × 时长」。纯启发式,用户随时可改。 */
|
||||
export function recommendSetup(input: { category?: string; targetAudience?: string; title?: string }): Combo & {
|
||||
duration: number;
|
||||
reason: string;
|
||||
} {
|
||||
const haystack = [input.category, input.title, input.targetAudience]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
let combo = FALLBACK_COMBO;
|
||||
let reason = "商品信息不足,先给一组最通用的";
|
||||
for (const rule of CATEGORY_RULES) {
|
||||
const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase()));
|
||||
if (hit) {
|
||||
combo = rule.combo;
|
||||
reason = `按「${hit}」品类推荐`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 防御:规则表若被改出禁用组合,这里兜住
|
||||
const structure = isForbidden(combo.format, combo.structure)
|
||||
? allowedStructures(combo.format)[0]
|
||||
: combo.structure;
|
||||
return {
|
||||
format: combo.format,
|
||||
structure,
|
||||
duration: recommendDuration(combo.format, structure),
|
||||
reason,
|
||||
};
|
||||
}
|
||||
@@ -510,7 +510,17 @@ export type Project = {
|
||||
storyboard_shots: StoryboardShot[];
|
||||
timeline: Timeline | null;
|
||||
metadata?: {
|
||||
wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };
|
||||
wizard?: {
|
||||
duration?: string;
|
||||
script_style?: string;
|
||||
persona?: string;
|
||||
selling_point_ids?: string[];
|
||||
// 二期:表现形式 × 视频结构 × 总时长(秒)。script_style 是一期的「风格」,已被 video_structure 取代,
|
||||
// 保留只为读旧项目的历史值,新稿不再写它。
|
||||
presentation_format?: string;
|
||||
video_structure?: string;
|
||||
total_duration?: number;
|
||||
};
|
||||
cast?: string[];
|
||||
scenes?: string[];
|
||||
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
|
||||
@@ -590,8 +600,10 @@ export type FreeVideoRef = {
|
||||
label?: string; // @mention 引用名
|
||||
thumb_url?: string;
|
||||
duration?: number; // 视频/音频时长(秒)
|
||||
asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id
|
||||
source?: "upload" | "library" | "library_group";
|
||||
// asset_id 的含义随 source 变:upload/asset → 平台 Asset id;library → 人物素材库 FreeAsset id
|
||||
asset_id?: string;
|
||||
// asset = 从资产库/模特库/商品库挑的平台资产(模块4 · 4.1),后端按来源+审核态判能不能引用
|
||||
source?: "upload" | "library" | "library_group" | "asset";
|
||||
group_id?: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user