feat: bootstrap commercial AI drama platform

This commit is contained in:
xz
2026-08-24 10:24:34 +08:00
commit ffb27d845b
100 changed files with 35314 additions and 0 deletions
+701
View File
@@ -0,0 +1,701 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
AudioLines,
Bot,
Check,
CheckCircle2,
CheckSquare2,
ChevronRight,
CircleAlert,
Download,
FileAudio,
FileImage,
FileText,
Film,
HardDrive,
FolderOpen,
Images,
Layers3,
LockKeyhole,
MessageSquareText,
Mic2,
Play,
Plus,
RefreshCw,
Search,
ShieldCheck,
Sparkles,
Square,
Tags,
Upload,
WandSparkles,
XCircle
} from "lucide-react";
import {
bindAssetToShot,
createAsset,
createAssetVersion,
fetchAsset,
fetchAssetContent,
fetchAssets,
fetchJobs,
queryAssistant,
restoreAssetVersion,
updateAssetLock,
updateAssetRights,
uploadAsset,
uploadAssetVersion,
verifyAsset
} from "../lib/api";
import { buildVoiceTable, downloadJson } from "../lib/exporters";
function SuiteHeader({ kicker, title, description, actions }) {
return (
<div className="suite-header">
<div>
<span className="card-kicker">{kicker}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
{actions && <div className="suite-header-actions">{actions}</div>}
</div>
);
}
function SuiteMetric({ icon: Icon, label, value, detail, tone = "neutral" }) {
return (
<div className={`suite-metric ${tone}`}>
<div className="suite-metric-icon"><Icon size={17} /></div>
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
</div>
);
}
function SuiteStatus({ status }) {
const config = {
locked: ["已锁定", "ok"],
ready: ["可用", "ok"],
draft: ["草稿", "neutral"],
pending: ["待处理", "warn"],
running: ["执行中", "warn"],
blocked: ["阻塞", "warn"],
review: ["待复核", "warn"],
archived: ["已归档", "neutral"],
"needs-evidence": ["待版权证据", "warn"],
completed: ["已完成", "ok"],
approved: ["已确认", "ok"],
"needs-user-approved-reference": ["待参考音频", "warn"]
}[status] || [status || "未设置", "neutral"];
return <span className={`suite-status ${config[1]}`}><span />{config[0]}</span>;
}
function AssetVisual({ asset }) {
const rawKind = asset.rawKind || asset.kind;
const Icon = rawKind === "character" || asset.kind === "角色" ? Images : rawKind === "location" || asset.kind === "场景" ? Film : rawKind === "prop" || asset.kind === "道具" ? Tags : rawKind === "voice" || asset.kind === "声音" ? FileAudio : FileImage;
const visualKind = rawKind === "character" || asset.kind === "角色" ? "character" : rawKind === "location" || asset.kind === "场景" ? "location" : rawKind === "prop" || asset.kind === "道具" ? "prop" : rawKind === "voice" || asset.kind === "声音" ? "voice" : "system";
return <div className={`asset-visual asset-${visualKind}`}><Icon size={22} /><strong>{asset.initial}</strong></div>;
}
const assetKindLabels = {
character: "角色",
location: "场景",
prop: "道具",
style: "风格",
lora: "LoRA",
voice: "声音",
subtitle: "字幕",
reference: "参考"
};
const assetKindOptions = [
["character", "角色"],
["location", "场景"],
["prop", "道具"],
["voice", "声音"],
["style", "风格"],
["lora", "LoRA"],
["reference", "参考"]
];
const MAX_ASSET_UPLOAD_BYTES = 12 * 1024 * 1024;
const ASSET_UPLOAD_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "wav", "mp3", "m4a", "mp4", "webm", "json", "txt", "md", "csv", "safetensors", "ckpt", "pt", "bin"]);
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
return `${(value / 1024 ** 3).toFixed(2)} GB`;
}
function validateAssetFile(file) {
if (!file) throw new Error("没有选择文件");
if (!file.size) throw new Error("不能上传空文件");
if (file.size > MAX_ASSET_UPLOAD_BYTES) throw new Error(`单次本地资产上传不能超过 12MB,当前文件为 ${formatBytes(file.size)}`);
const extension = file.name.toLowerCase().split(".").pop();
const supported = file.type.startsWith("image/") || file.type.startsWith("audio/") || file.type.startsWith("video/") || file.type.startsWith("text/") || file.type === "application/json" || ASSET_UPLOAD_EXTENSIONS.has(extension);
if (!supported) throw new Error(`暂不支持 ${extension ? `.${extension}` : "该类型"} 文件,请导入图片、音频、视频、文本、JSON 或本地模型文件`);
return file;
}
function isTextAsset(fileName = "", contentType = "") {
return contentType.startsWith("text/") || contentType.includes("json") || /\.(json|txt|md|csv)$/i.test(fileName);
}
function normalizeAsset(row) {
const metadata = row.currentVersion?.metadata || {};
const rawKind = row.kind || "reference";
const currentVersion = row.currentVersion || {};
return {
...row,
rawKind,
kind: assetKindLabels[rawKind] || rawKind,
status: row.lockStatus || row.lock_status || "draft",
currentVersionId: row.currentVersionId || row.current_version_id || row.currentVersion?.id || "",
version: row.currentVersion?.version_number ? `v${row.currentVersion.version_number}` : "v1",
initial: metadata.initial || row.name?.slice(0, 1) || "资",
subtitle: metadata.subtitle || "本地项目资产",
usage: metadata.usage || "当前项目",
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
detail: metadata.detail || "待补充资产描述",
lock: metadata.lock || "待补充连续性备注",
rightsStatus: row.currentVersion?.rights_status || "needs-evidence",
versions: row.versions || [],
bindings: row.bindings || [],
fileName: currentVersion.fileName || currentVersion.file_name || "",
mimeType: currentVersion.mimeType || currentVersion.mime_type || metadata.mimeType || "application/octet-stream",
fileSize: Number(currentVersion.fileSize ?? currentVersion.file_size ?? metadata.size ?? 0),
contentSha256: currentVersion.contentSha256 || currentVersion.content_sha256 || ""
};
}
function readFileBase64(file) {
return file.arrayBuffer().then((buffer) => {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
});
}
function inferAssetKind(file) {
const name = file.name.toLowerCase();
if (name.endsWith(".safetensors") || name.endsWith(".ckpt") || name.endsWith(".pt")) return "lora";
if (file.type.startsWith("audio/")) return "voice";
if (file.type === "application/json" || name.endsWith(".json")) return "style";
return "reference";
}
export function AssetLibraryPage({ project, contextOverrides }) {
const [filter, setFilter] = useState("全部");
const [query, setQuery] = useState("");
const [assets, setAssets] = useState([]);
const [selectedId, setSelectedId] = useState("");
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [busy, setBusy] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState("");
const [previewText, setPreviewText] = useState("");
const [previewError, setPreviewError] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const [showBinding, setShowBinding] = useState(false);
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
const [pendingFile, setPendingFile] = useState(null);
const [bindingForm, setBindingForm] = useState({ shotId: project.shots[0]?.id || "", usageRole: "continuity" });
const inputRef = useRef(null);
const versionInputRef = useRef(null);
const filtered = assets.filter((asset) => (filter === "全部" || asset.kind === filter) && `${asset.name} ${asset.subtitle} ${asset.tags.join(" ")}`.toLowerCase().includes(query.toLowerCase()));
const selected = assets.find((asset) => asset.id === selectedId) || filtered[0] || assets[0];
const lockedCount = assets.filter((asset) => asset.status === "locked").length;
async function loadAssets() {
setLoading(true);
try {
const payload = await fetchAssets(contextOverrides);
const next = (payload.assets || []).map(normalizeAsset);
setAssets(next);
setSelectedId((current) => next.some((item) => item.id === current) ? current : next[0]?.id || "");
setLoadError("");
} catch (error) {
setLoadError(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { loadAssets(); }, [project.series?.id, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
useEffect(() => {
let cancelled = false;
let objectUrl = "";
async function loadPreview() {
setPreviewUrl("");
setPreviewType("");
setPreviewText("");
setPreviewError("");
setPreviewLoading(false);
if (!selected?.id || !selected.currentVersion?.storage_path || selected.currentVersion.storage_path.endsWith("metadata.json")) return;
setPreviewLoading(true);
try {
const payload = await fetchAssetContent(selected.id, contextOverrides);
if (cancelled) return;
setPreviewType(payload.contentType);
if (isTextAsset(selected.fileName, payload.contentType)) {
const text = await payload.blob.text();
if (!cancelled) setPreviewText(text.slice(0, 24000));
} else {
objectUrl = URL.createObjectURL(payload.blob);
if (!cancelled) setPreviewUrl(objectUrl);
}
} catch (error) {
if (!cancelled) setPreviewError(error.message);
} finally {
if (!cancelled) setPreviewLoading(false);
}
}
loadPreview();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [selected?.id, selected?.currentVersion?.storage_path, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
function replaceAsset(nextAsset) {
const normalized = normalizeAsset(nextAsset);
setAssets((current) => current.some((item) => item.id === normalized.id) ? current.map((item) => item.id === normalized.id ? normalized : item) : [normalized, ...current]);
setSelectedId(normalized.id);
return normalized;
}
function prepareUpload(file) {
try {
const checked = validateAssetFile(file);
const name = checked.name.replace(/\.[^.]+$/, "") || checked.name;
setPendingFile(checked);
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
setShowUpload(true);
setNotice("");
} catch (error) {
setNotice(error.message);
}
}
async function handleUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = "";
prepareUpload(file);
}
function handleDrop(event) {
event.preventDefault();
prepareUpload(event.dataTransfer.files?.[0]);
}
async function submitUpload(event) {
event.preventDefault();
if (!pendingFile) return;
setBusy(true);
try {
const asset = await uploadAsset({
data: await readFileBase64(pendingFile),
fileName: pendingFile.name,
name: uploadForm.name.trim() || pendingFile.name,
kind: uploadForm.kind,
subtitle: uploadForm.subtitle,
usage: uploadForm.usage,
detail: uploadForm.detail,
lock: uploadForm.lock,
mimeType: pendingFile.type || "application/octet-stream",
rightsStatus: uploadForm.rightsStatus,
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
}, contextOverrides);
replaceAsset(asset.asset);
setShowUpload(false);
setPendingFile(null);
setNotice(`本地资产“${pendingFile.name}”已写入资产库,SHA-256、文件大小和版本台账已登记。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function submitCreate(event) {
event.preventDefault();
if (!assetForm.name.trim()) return;
setBusy(true);
try {
const payload = await createAsset({ ...assetForm, name: assetForm.name.trim(), tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean) }, contextOverrides);
replaceAsset(payload.asset);
setShowCreate(false);
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
setNotice(`资产“${payload.asset.name}”已创建为 v1 草稿。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function toggleLock() {
if (!selected) return;
setBusy(true);
try {
const nextStatus = selected.status === "locked" ? "review" : "locked";
const payload = await updateAssetLock(selected.id, nextStatus, contextOverrides);
replaceAsset(payload.asset);
setNotice(`${selected.name} 已${nextStatus === "locked" ? "锁定" : "转为待复核"},后续镜头会读取最新状态。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function bindSelected(event) {
event.preventDefault();
if (!selected) return;
setBusy(true);
try {
const payload = await bindAssetToShot(selected.id, bindingForm, contextOverrides);
replaceAsset(payload.asset);
setShowBinding(false);
setNotice(`${selected.name} 已绑定到 ${bindingForm.shotId},生成任务会继承该资产版本。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function createVersion() {
if (!selected) return;
setBusy(true);
try {
const payload = await createAssetVersion(selected.id, { versionNote: "从资产检查器创建的新版本", rightsStatus: selected.rightsStatus }, contextOverrides);
replaceAsset(payload.asset);
setNotice(`${selected.name} 已创建 ${normalizeAsset(payload.asset).version} 版本。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function handleVersionUpload(event) {
const file = event.target.files?.[0];
if (!file || !selected) return;
event.target.value = "";
try {
validateAssetFile(file);
} catch (error) {
setNotice(error.message);
return;
}
setBusy(true);
try {
const payload = await uploadAssetVersion(selected.id, {
data: await readFileBase64(file),
fileName: file.name,
mimeType: file.type || "application/octet-stream",
rightsStatus: selected.rightsStatus,
versionNote: "从资产库上传新文件版本"
}, contextOverrides);
replaceAsset(payload.asset);
setNotice(`${selected.name} 已上传文件版本 v${normalizeAsset(payload.asset).version},SHA-256 已登记。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function verifySelected() {
if (!selected) return;
setBusy(true);
try {
const payload = await verifyAsset(selected.id, contextOverrides);
setNotice(payload.verification.verified ? `${selected.name} 当前版本完整性校验通过。` : `${selected.name} 当前版本完整性校验失败,请检查文件是否被替换。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function downloadSelected() {
if (!selected?.id) return;
setBusy(true);
try {
const payload = await fetchAssetContent(selected.id, contextOverrides);
const url = URL.createObjectURL(payload.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = selected.fileName || `${selected.name}-v${selected.currentVersion?.version_number || 1}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setNotice(`${selected.name} 当前版本已准备本地下载。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function restoreVersion(version) {
if (!selected || selected.currentVersionId === version.id) return;
setBusy(true);
try {
const payload = await restoreAssetVersion(selected.id, version.id, contextOverrides);
replaceAsset(payload.asset);
setShowVersions(false);
setNotice(`${selected.name} 已恢复到 v${version.version_number},后续镜头将继承该版本。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
return (
<div className="suite-page">
<SuiteHeader
kicker="CREATOR SUITE / ASSET LIBRARY"
title="资产库"
description={`${project.series.title} · 角色、场景、道具、风格和本地 LoRA 统一管理,所有生产镜头从锁定资产继承。`}
actions={<><input ref={inputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleUpload} /><input ref={versionInputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleVersionUpload} /><button className="subtle" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={15} />导入本地资产</button><button className="primary" onClick={() => setShowCreate(true)} disabled={busy}><Plus size={15} />新建资产</button></>}
/>
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
{loadError && <div className="suite-notice warning"><CircleAlert size={15} />资产库读取失败:{loadError}<button onClick={loadAssets} disabled={loading}><RefreshCw size={14} />重试</button></div>}
<div className="suite-metric-grid">
<SuiteMetric icon={Images} label="资产总数" value={assets.length} detail="当前项目可复用" tone="ok" />
<SuiteMetric icon={LockKeyhole} label="已锁定" value={`${lockedCount}/${assets.length}`} detail="连续性继承源" tone="ok" />
<SuiteMetric icon={RefreshCw} label="版本" value={assets.reduce((sum, item) => sum + item.versions.length, 0)} detail="已登记版本" />
<SuiteMetric icon={ShieldCheck} label="版权证据" value={`${assets.filter((item) => item.rightsStatus === "approved").length}/${assets.length}`} detail="未确认资产不会进入正式批次" tone={assets.some((item) => item.rightsStatus !== "approved") ? "warn" : "ok"} />
</div>
<div className="asset-library-layout">
<aside className="suite-filter-rail">
<span className="suite-filter-title">资产类型</span>
{["全部", ...assetKindOptions.map(([, label]) => label)].map((item) => <button key={item} className={filter === item ? "selected" : ""} onClick={() => setFilter(item)}>{item}<span>{item === "全部" ? assets.length : assets.filter((asset) => asset.kind === item).length}</span></button>)}
<div className="suite-rail-divider" />
<span className="suite-filter-title">锁定状态</span>
<div className="suite-rail-note"><LockKeyhole size={14} /><span>角色、服装、道具、天气和镜头继承当前 ledger。</span></div>
</aside>
<section className="suite-library-main">
<div className="suite-toolbar"><div className="suite-search"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索资产、标签或角色" /></div><span>{loading ? "读取中…" : `${filtered.length} 个资产`}</span></div>
<div className="asset-intake-strip" onDragOver={(event) => event.preventDefault()} onDrop={handleDrop}>
<div className="asset-intake-copy"><HardDrive size={17} /><div><strong>本地资产入口</strong><span>单文件不超过 12MB,导入后自动登记 SHA-256、版本和本地存储路径。</span></div></div>
<button className="icon-text-button" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={14} />选择文件</button>
</div>
<div className="asset-card-grid">
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><SuiteStatus status={asset.status} /></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
</div>
{!filtered.length && <div className="suite-empty"><Search size={18} />{loading ? "正在读取资产库…" : "没有匹配资产"}</div>}
</section>
{selected && <aside className="asset-inspector"><div className="inspector-heading"><div><span className="card-kicker">ASSET INSPECTOR</span><h3>{selected.name}</h3></div><SuiteStatus status={selected.status} /></div>{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={verifySelected} disabled={busy}><Check size={13} />校验</button></div>}<dl className="suite-definition-list"><dt>类型</dt><dd>{selected.kind}</dd><dt>版本</dt><dd>{selected.version}</dd><dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd><dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd><dt>使用范围</dt><dd>{selected.usage}</dd><dt>版权/授权</dt><dd>{selected.rightsStatus === "approved" ? "已确认" : "待补证据"}</dd><dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd></dl><div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div><div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div><div className="inspector-actions"><button className="subtle" onClick={() => setShowVersions(true)}><RefreshCw size={15} />版本记录</button><button className="subtle" onClick={downloadSelected} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button><button className="primary" onClick={() => setShowBinding(true)}><LockKeyhole size={15} />绑定到镜头</button><button className="subtle" onClick={toggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button></div></aside>}
</div>
{showUpload && pendingFile && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}><form className="suite-modal" onSubmit={submitUpload}><div className="suite-modal-head"><div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div><button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button></div><div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div><div className="suite-form-grid"><label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label><label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label><label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label><label>标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label><label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label><label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div></form></div>}
{showCreate && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}><form className="suite-modal" onSubmit={submitCreate}><div className="suite-modal-head"><div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div><button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="suite-form-grid"><label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label><label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label><label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label><label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label><label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div></form></div>}
{showVersions && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowVersions(false)}><section className="suite-modal"><div className="suite-modal-head"><div><span className="card-kicker">VERSION HISTORY</span><h3>{selected.name}</h3></div><button className="icon-only-button" onClick={() => setShowVersions(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="version-list">{selected.versions.map((version) => <div key={version.id}><div><strong>v{version.version_number}{selected.currentVersionId === version.id ? " · 当前" : ""}</strong><span>{version.metadata?.versionNote || "版本记录"}</span></div><SuiteStatus status={version.rights_status === "approved" ? "approved" : "needs-evidence"} /><small>{version.storage_path}{version.content_sha256 ? ` · SHA ${version.content_sha256.slice(0, 12)}…` : ""}</small>{selected.currentVersionId !== version.id && <button className="icon-text-button" onClick={() => restoreVersion(version)} disabled={busy}><RefreshCw size={13} />恢复此版本</button>}</div>)}</div><div className="suite-modal-actions"><button className="subtle" onClick={() => setShowVersions(false)}>关闭</button><button className="subtle" onClick={() => versionInputRef.current?.click()} disabled={busy}><Upload size={15} />上传新版本</button><button className="primary" onClick={createVersion} disabled={busy}><Plus size={15} />创建空白版本</button></div></section></div>}
{showBinding && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowBinding(false)}><form className="suite-modal compact" onSubmit={bindSelected}><div className="suite-modal-head"><div><span className="card-kicker">SHOT BINDING</span><h3>绑定到镜头</h3></div><button type="button" className="icon-only-button" onClick={() => setShowBinding(false)} aria-label="关闭"><XCircle size={17} /></button></div><p className="suite-modal-copy">绑定后,生成任务会把“{selected.name}”的当前版本作为连续性输入。</p><div className="suite-form-grid"><label>镜头<select value={bindingForm.shotId} onChange={(event) => setBindingForm((current) => ({ ...current, shotId: event.target.value }))}>{project.shots.map((shot) => <option key={shot.id} value={shot.id}>{shot.id} · {shot.title}</option>)}</select></label><label>使用角色<select value={bindingForm.usageRole} onChange={(event) => setBindingForm((current) => ({ ...current, usageRole: event.target.value }))}><option value="character">角色</option><option value="location">场景</option><option value="prop">道具</option><option value="style">风格</option><option value="continuity">连续性</option></select></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowBinding(false)}>取消</button><button type="submit" className="primary" disabled={busy}><LockKeyhole size={15} />确认绑定</button></div></form></div>}
</div>
);
}
export function VoiceStudioPage({ project, onQueueJob, contextOverrides, canApproveVoice = false }) {
const lines = useMemo(() => project.shots.flatMap((shot) => shot.voiceLines.map((line) => ({ ...line, shotId: shot.id, shotTitle: shot.title }))), [project]);
const jobs = project.productionJobs || [];
const [view, setView] = useState("profiles");
const [selectedLineId, setSelectedLineId] = useState(lines[0]?.id || "");
const [auditionedLocal, setAuditionedLocal] = useState([]);
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [voiceAssets, setVoiceAssets] = useState([]);
const [voiceAssetsLoading, setVoiceAssetsLoading] = useState(true);
const [rightsNotes, setRightsNotes] = useState({});
const selectedLine = lines.find((line) => line.id === selectedLineId) || lines[0];
const lockedVoices = voiceAssetsLoading ? project.characters.filter((character) => character.voiceLock?.status === "approved").length : voiceAssets.filter((asset) => asset.currentVersion?.rights_status === "approved").length;
const auditioned = useMemo(() => new Set([
...auditionedLocal,
...jobs.filter((job) => job.kind.includes("TTS") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id)).map((line) => line.id))
]), [auditionedLocal, jobs, lines]);
const aligned = useMemo(() => new Set(jobs.filter((job) => job.kind.includes("ASR") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id) || job.output.endsWith("/alignment.json") && job.shotId === line.shotId).map((line) => line.id))), [jobs, lines]);
const alignmentPercent = lines.length ? Math.round((aligned.size / lines.length) * 100) : 0;
async function loadVoiceAssets() {
setVoiceAssetsLoading(true);
try {
const payload = await fetchAssets(contextOverrides);
const next = (payload.assets || []).filter((asset) => asset.kind === "voice");
setVoiceAssets(next);
setRightsNotes((current) => Object.fromEntries(next.map((asset) => [asset.id, current[asset.id] || asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""])));
} catch (error) {
setNotice(error.message);
} finally {
setVoiceAssetsLoading(false);
}
}
useEffect(() => { loadVoiceAssets(); }, [contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
async function updateVoiceRights(asset, rightsStatus) {
setBusy(true);
try {
const reference = String(rightsNotes[asset.id] || "").trim();
const result = await updateAssetRights(asset.id, { rightsStatus, evidence: { reference, source: "voice-studio-review" } }, contextOverrides);
setVoiceAssets((current) => current.map((item) => item.id === asset.id ? result.asset : item));
setNotice(`${asset.name} 已更新为“${rightsStatus === "approved" ? "已确认" : rightsStatus === "submitted" ? "已提交证据" : rightsStatus === "rejected" ? "已驳回" : rightsStatus}”。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function audition() {
if (!selectedLine) return;
setBusy(true);
try {
await onQueueJob?.("单句 TTS 试听", selectedLine.shotId, { adapter: "local-tts", output: `voices/auditions/${selectedLine.id}.wav` });
setAuditionedLocal((current) => [...new Set([...current, selectedLine.id])]);
setNotice(`已将“${selectedLine.id}”作为单句试听写入本地队列。正式批量配音仍需用户确认参考音频。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
async function runAlignment() {
if (!selectedLine) return;
setBusy(true);
try {
await onQueueJob?.("ASR 台词校验", selectedLine.shotId, { adapter: "owned-model-platform", output: `qa/${selectedLine.shotId}/${selectedLine.id}-alignment.json` });
setNotice(`已将“${selectedLine.id}”的 ASR/字幕对齐任务写入队列。完成后会回填词级时间轴。`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy(false);
}
}
return (
<div className="suite-page">
<SuiteHeader kicker="CREATOR SUITE / VOICE & SUBTITLE" title="声音与字幕" description="固定角色声线、单句试听、字幕台词和 ASR 对齐集中管理;未确认的参考音频不会进入批量生产。" actions={<button className="subtle" onClick={() => setNotice("当前声音策略:IndexTTS-2.5 / paraformer-zh-long;H3 原生音轨和旧 probe 不可作为正式参考音频。")}><ShieldCheck size={15} />查看声音策略</button>} />
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
<div className="suite-metric-grid"><SuiteMetric icon={Mic2} label="角色声线" value={`${project.characters.length}`} detail={`${lockedVoices} 个已确认`} tone={lockedVoices ? "ok" : "warn"} /><SuiteMetric icon={MessageSquareText} label="台词" value={`${lines.length} 句`} detail="中文对白" /><SuiteMetric icon={AudioLines} label="试听记录" value={`${auditioned.size}`} detail="单句,不批量" tone="ok" /><SuiteMetric icon={CheckCircle2} label="ASR 对齐" value={`${alignmentPercent}%`} detail={`${aligned.size}/${lines.length} 句已有任务`} tone={alignmentPercent === 100 ? "ok" : "warn"} /></div>
<div className="voice-layout">
<section className="studio-card voice-main-card"><div className="suite-tabs">{[["profiles", "角色声线", Mic2], ["lines", "台词表", MessageSquareText], ["alignment", "ASR 对齐", AudioLines]].map(([id, label, Icon]) => <button key={id} className={view === id ? "selected" : ""} onClick={() => setView(id)}><Icon size={15} />{label}</button>)}</div>
{view === "profiles" && <div className="voice-profile-grid">{project.characters.map((character) => <article key={character.id} className="voice-profile"><div className="voice-avatar">{character.name.slice(0, 1)}</div><div className="voice-profile-main"><div className="voice-profile-title"><strong>{character.name}</strong><SuiteStatus status={character.voiceLock?.status} /></div><span>{character.voiceLock?.voiceId}</span><p>{character.voiceLock?.tone}</p><div className="voice-meta"><span>{character.voiceLock?.ttsModel}</span><span>{character.voiceLock?.asrModel}</span></div></div><button className="icon-text-button" onClick={() => setNotice(`${character.name}:${character.voiceLock?.referencePolicy}`)}><ChevronRight size={15} />详情</button></article>)}</div>}
{view === "lines" && <div className="voice-lines-table"><div className="voice-lines-head"><span>台词</span><span>角色</span><span>镜头</span><span>目标时长</span><span>口型策略</span><span>状态</span></div>{lines.map((line) => <button key={line.id} className={selectedLine?.id === line.id ? "selected" : ""} onClick={() => setSelectedLineId(line.id)}><strong>{line.text}</strong><span>{project.characters.find((character) => character.id === line.characterId)?.name}</span><span>{line.shotId}</span><span>{line.targetDurationSec}s</span><span>{line.mouthPlan}</span><SuiteStatus status={auditioned.has(line.id) ? "ready" : "pending"} /></button>)}</div>}
{view === "alignment" && <div className="alignment-list">{lines.map((line) => <div key={line.id}><div><strong>{line.id} · {line.text}</strong><span>{line.shotTitle} · {line.audioFile}</span></div><div className="alignment-track"><span style={{ width: aligned.has(line.id) ? "100%" : auditioned.has(line.id) ? "38%" : "0%" }} /></div><SuiteStatus status={aligned.has(line.id) ? "ready" : auditioned.has(line.id) ? "running" : "pending"} /></div>)}</div>}
</section>
<aside className="studio-card voice-inspector"><div className="card-heading-row"><div><span className="card-kicker">LINE INSPECTOR</span><h3>单句试听</h3></div><FileAudio size={18} /></div>{selectedLine ? <><div className="selected-line"><span>{selectedLine.shotId} · {selectedLine.id}</span><strong>{selectedLine.text}</strong><small>{selectedLine.emotion} · 目标 {selectedLine.targetDurationSec}s</small></div><div className="voice-lock-box"><ShieldCheck size={16} /><div><strong>正式声音门</strong><p>必须使用用户确认的自然参考音频。正脸长对白不作为默认镜头策略。</p></div></div><div className="voice-action-stack"><button className="primary full-width" onClick={audition} disabled={busy}><Play size={15} />生成这一句试听</button><button className="subtle full-width" onClick={runAlignment} disabled={busy}><AudioLines size={15} />运行 ASR 对齐</button><button className="subtle full-width" onClick={() => { downloadJson(`${project.episode.id}-voice-lines.json`, buildVoiceTable(project)); setNotice("字幕字段 JSON 已准备下载,包含角色、台词、镜头、目标时长和 ASR 字段。"); }}><FolderOpen size={15} />下载字幕字段 JSON</button></div></> : <div className="suite-empty">暂无台词</div>}</aside>
</div>
<section className="studio-card voice-approval-card"><div className="card-heading-row"><div><span className="card-kicker">VOICE RIGHTS / APPROVAL</span><h3>参考音频授权台账</h3><p className="muted-copy">声音资产必须有可追溯的授权证据;未批准的参考音频只能做单句试听,不能进入批量 TTS。</p></div><ShieldCheck size={18} /></div>{voiceAssetsLoading ? <div className="suite-empty">正在读取声音资产…</div> : voiceAssets.length ? <div className="voice-rights-list">{voiceAssets.map((asset) => { const rightsStatus = asset.currentVersion?.rights_status || "needs-evidence"; const evidence = asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""; return <div className="voice-rights-row" key={asset.id}><div className="voice-rights-title"><div className="voice-avatar"><FileAudio size={16} /></div><div><strong>{asset.name}</strong><span>{asset.currentVersion?.version_number ? `v${asset.currentVersion.version_number}` : "v1"} · {asset.currentVersion?.storage_path}</span></div></div><SuiteStatus status={rightsStatus} /><input aria-label={`${asset.name} 授权证据引用`} value={rightsNotes[asset.id] ?? evidence} onChange={(event) => setRightsNotes((current) => ({ ...current, [asset.id]: event.target.value }))} placeholder="授权合同、同意书或本地证据路径" /><div className="voice-rights-actions"><button className="subtle" onClick={() => updateVoiceRights(asset, "submitted")} disabled={busy}>提交证据</button>{canApproveVoice && <button className="primary" onClick={() => updateVoiceRights(asset, "approved")} disabled={busy || !String(rightsNotes[asset.id] || evidence).trim()}>批准使用</button>}<button className="danger-button" onClick={() => updateVoiceRights(asset, "rejected")} disabled={busy}>驳回</button></div></div>; })}</div> : <div className="suite-empty">当前项目还没有声音资产,请先导入一条本地参考音频。</div>}</section>
</div>
);
}
function jobStatus(project, shotId, kind) {
const job = (project.productionJobs || []).find((item) => item.shotId === shotId && item.kind.includes(kind));
return job?.status || "pending";
}
export function BatchProductionPage({ project, onQueueJob, contextOverrides }) {
const [selectedShots, setSelectedShots] = useState(project.shots.map((shot) => shot.id));
const [taskKind, setTaskKind] = useState("单画面关键帧");
const [notice, setNotice] = useState("");
const [executionLogs, setExecutionLogs] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const allSelected = selectedShots.length === project.shots.length;
function toggleShot(id) { setSelectedShots((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]); }
async function runBatch() {
if (!selectedShots.length) { setNotice("请先选择至少一个镜头。"); return; }
setNotice(`正在将 ${selectedShots.length} 个镜头拆成独立任务…`);
try {
for (const shotId of selectedShots) await onQueueJob?.(taskKind, shotId);
setNotice(`已写入 ${selectedShots.length} 个独立任务;每个镜头仍保持一次一个完整画面。`);
} catch (error) { setNotice(error.message); }
}
async function loadExecutionLogs() {
setShowLogs(true);
setLogsLoading(true);
try {
const result = await fetchJobs({ ...contextOverrides, projectId: contextOverrides?.projectId });
setExecutionLogs(result.jobs || []);
} catch (error) {
setNotice(`执行日志读取失败:${error.message}`);
} finally {
setLogsLoading(false);
}
}
return (
<div className="suite-page">
<SuiteHeader kicker="CREATOR SUITE / BATCH PRODUCTION" title="批量生产" description="把选中的镜头拆成可追踪的独立 job,逐镜头继承角色锁、场景锁、实际末帧和 local-only 成本策略。" actions={<button className="primary" onClick={runBatch}><WandSparkles size={15} />运行选中任务</button>} />
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
<div className="batch-control-bar"><div className="batch-kind"><span>任务类型</span><select value={taskKind} onChange={(event) => setTaskKind(event.target.value)}><option>单画面关键帧</option><option>首尾帧图生视频</option><option>批量跑单画面 QA</option><option>剪辑合成清单</option></select></div><div className="batch-scope"><span>当前范围</span><strong>{selectedShots.length}/{project.shots.length} 个镜头</strong><button className="context-link" onClick={() => setSelectedShots(allSelected ? [] : project.shots.map((shot) => shot.id))}>{allSelected ? <Square size={15} /> : <CheckSquare2 size={15} />}{allSelected ? "清空" : "全选"}</button></div><div className="batch-policy"><ShieldCheck size={15} /><span>local-only · 禁止拼图/多格/多时间点</span></div></div>
<div className="batch-layout"><section className="studio-card wide-card"><div className="section-bar"><div><h3>镜头批次</h3><span>每行对应一个可重试、可审计的生成任务</span></div><span className="suite-status ok"><span />{project.shots.length} shots</span></div><div className="batch-table"><div className="batch-table-head"><span>选择</span><span>镜头</span><span>连续性源</span><span>关键帧</span><span>视频</span><span>声音</span><span>QA</span></div>{project.shots.map((shot) => <button key={shot.id} className={selectedShots.includes(shot.id) ? "selected" : ""} onClick={() => toggleShot(shot.id)}><span>{selectedShots.includes(shot.id) ? <CheckSquare2 size={17} /> : <Square size={17} />}</span><div><strong>{shot.id} · {shot.title}</strong><small>{shot.durationSec}s · {shot.camera}</small></div><span className="mono">{shot.firstFrame === "AUTO_PREVIOUS_ACTUAL_LAST_FRAME" ? "actual-last-frame" : "episode-start"}</span><SuiteStatus status={jobStatus(project, shot.id, "关键帧")} /><SuiteStatus status={jobStatus(project, shot.id, "图生视频")} /><SuiteStatus status={jobStatus(project, shot.id, "TTS")} /><SuiteStatus status={jobStatus(project, shot.id, "QA")} /></button>)}</div></section><aside className="studio-card"><div className="card-heading-row"><div><span className="card-kicker">BATCH CONTRACT</span><h3>执行约束</h3></div><CircleAlert size={18} /></div><ul className="batch-contract-list"><li><Check size={14} />每个任务只绑定一个镜头</li><li><Check size={14} />图片输出数量固定为 1</li><li><Check size={14} />shot-02 / shot-03 继承上一段实际末帧</li><li><Check size={14} />云端连接器默认不进入批次</li><li><Check size={14} />失败后保留 job、attempt 和证据</li></ul><button className="subtle full-width" onClick={loadExecutionLogs}><FolderOpen size={15} />查看执行日志</button></aside></div>
{showLogs && <section className="studio-card batch-log-card"><div className="card-heading-row"><div><span className="card-kicker">EXECUTION LOG</span><h3>批次执行日志</h3></div><div className="row-actions"><button className="subtle" onClick={loadExecutionLogs} disabled={logsLoading}><RefreshCw size={14} />刷新</button><button className="icon-only-button" onClick={() => setShowLogs(false)} title="关闭执行日志" aria-label="关闭执行日志"><XCircle size={15} /></button></div></div>{logsLoading ? <div className="suite-empty">读取任务日志…</div> : <div className="batch-log-table"><div><span>任务</span><span>镜头</span><span>状态</span><span>尝试</span><span>错误</span></div>{executionLogs.map((job) => <div key={job.id}><strong>{job.kind}</strong><span>{job.shotId || job.shot_id || "全局"}</span><SuiteStatus status={job.status} /><span>{job.attempts || job.attemptLog?.length || 0}</span><span>{job.errorMessage || "-"}</span></div>)}{!executionLogs.length && <div className="suite-empty">当前范围没有任务日志。</div>}</div>}</section>}
</div>
);
}
export function ProjectAssistantPage({ project, setActiveTab, allowedTabs = [] }) {
const [messages, setMessages] = useState([{ role: "assistant", text: `我已载入《${project.series.title}》当前项目数据。可以检查连续性、拆解镜头、生成单画面 prompt 或整理交付清单。` }]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const actions = [
{ id: "continuity", label: "检查连续性", icon: ShieldCheck, target: "qa" },
{ id: "shot", label: "拆成镜头", icon: ClapperboardIcon, target: "director" },
{ id: "prompt", label: "整理 prompt pack", icon: WandSparkles, target: "director" },
{ id: "delivery", label: "检查交付", icon: FolderOpen, target: "export" }
];
const visibleActions = actions.filter((action) => !allowedTabs.length || allowedTabs.includes(action.target));
function ClapperboardIcon(props) { return <Film {...props} />; }
async function ask(question, action) {
setBusy(true);
setMessages((current) => [...current, { role: "user", text: question }]);
try {
const result = await queryAssistant({ question, actionId: action?.id || "freeform" });
setMessages((current) => [...current, { role: "assistant", text: result.answer || "项目助手没有返回可执行结果。" }]);
const target = result.suggestedTabs?.find((tab) => !allowedTabs.length || allowedTabs.includes(tab)) || action?.target;
if (target && (!allowedTabs.length || allowedTabs.includes(target))) setActiveTab(target);
} catch (error) {
setMessages((current) => [...current, { role: "assistant", text: `项目助手请求失败:${error.message}` }]);
} finally {
setBusy(false);
}
}
function runAction(action) {
ask(action.label, action);
}
function submit(event) {
event.preventDefault();
const text = input.trim();
if (!text || busy) return;
setInput("");
ask(text);
}
return (
<div className="suite-page">
<SuiteHeader kicker="CREATOR SUITE / PROJECT ASSISTANT" title="项目 AI 助手" description="助手只读取当前组织、工作区和项目上下文,输出可落到剧本、资产、分镜、QA 或交付模块的生产动作。" actions={<span className="assistant-local-badge"><Bot size={15} />本地规则 + 项目数据</span>} />
<div className="assistant-layout"><section className="studio-card assistant-chat"><div className="assistant-chat-head"><div><span className="card-kicker">PROJECT CONTEXT</span><h3>{project.series.title} · {project.episode.title}</h3></div><span className="suite-status ok"><span />已连接</span></div><div className="assistant-message-list">{messages.map((message, index) => <div key={`${message.role}-${index}`} className={`assistant-message ${message.role}`}><span className="assistant-message-avatar">{message.role === "assistant" ? <Bot size={15} /> : "我"}</span><p>{message.text}</p></div>)}{busy && <div className="assistant-message assistant"><span className="assistant-message-avatar"><Bot size={15} /></span><p className="typing-dots">正在读取项目锁…</p></div>}</div><form className="assistant-input" onSubmit={submit}><input value={input} onChange={(event) => setInput(event.target.value)} placeholder="输入要检查的镜头、角色或交付问题" /><button className="primary" type="submit" disabled={busy}><MessageSquareText size={15} />发送</button></form></section><aside className="assistant-action-panel"><div className="card-heading-row"><div><span className="card-kicker">PRODUCTION ACTIONS</span><h3>快捷动作</h3></div><Sparkles size={18} /></div>{visibleActions.map((action) => { const Icon = action.icon; return <button key={action.id} className="assistant-action" disabled={busy} onClick={() => runAction(action)}><span className="assistant-action-icon"><Icon size={16} /></span><span><strong>{action.label}</strong><small>调用项目助手并进入对应生产模块</small></span><ChevronRight size={15} /></button>; })}<div className="assistant-context-card"><strong>当前硬约束</strong><span>一图一画面</span><span>实际末帧连续</span><span>固定声线</span><span>原创 / 本地优先</span></div></aside></div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import { AlertTriangle, CheckCircle2, Download, Film, LockKeyhole, MessageSquare, RefreshCw, Send } from "lucide-react";
import { fetchPublicDeliveryPortal, publicDeliveryFileUrl, submitPublicDeliveryFeedback } from "../lib/api";
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", { hour12: false });
}
export function DeliveryPortalPage({ token }) {
const [payload, setPayload] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [feedbackName, setFeedbackName] = useState("");
const [feedbackEmail, setFeedbackEmail] = useState("");
const [feedbackMessage, setFeedbackMessage] = useState("");
const [feedbackBusy, setFeedbackBusy] = useState(false);
const [feedbackNotice, setFeedbackNotice] = useState("");
async function load() {
setLoading(true);
try {
const result = await fetchPublicDeliveryPortal(token);
setPayload(result);
setFeedbackName(result.review?.reviewerName || result.portal?.recipientName || "");
setError(null);
} catch (nextError) {
setPayload(null);
setError(nextError);
} finally {
setLoading(false);
}
}
async function submitFeedback(decision) {
if (decision === "changes_requested" && !feedbackMessage.trim()) {
setFeedbackNotice("提出修改意见前,请填写具体反馈。");
return;
}
setFeedbackBusy(true);
setFeedbackNotice("");
try {
const result = await submitPublicDeliveryFeedback(token, {
decision,
reviewerName: feedbackName,
reviewerEmail: feedbackEmail,
message: feedbackMessage
});
setPayload(result);
setFeedbackMessage("");
setFeedbackNotice(decision === "approved" ? "已记录验收结果,感谢确认。" : "修改意见已提交给项目方。");
} catch (nextError) {
setFeedbackNotice(nextError.message);
} finally {
setFeedbackBusy(false);
}
}
useEffect(() => {
load();
}, [token]);
if (loading) {
return <div className="delivery-portal-shell"><div className="delivery-portal-loading"><RefreshCw size={18} />正在读取交付资料…</div></div>;
}
if (error || !payload) {
const expired = error?.status === 410;
return <div className="delivery-portal-shell"><main className="delivery-portal-error"><div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div><div className="delivery-portal-error-icon"><AlertTriangle size={28} /></div><h1>{expired ? "访问链接已过期" : "暂时无法打开交付资料"}</h1><p>{expired ? "请联系项目方重新生成一个有效的交付访问链接。" : "链接可能已撤销、输入有误,或对应版本已经下线。"}</p><button className="primary" type="button" onClick={load}><RefreshCw size={15} />重新检查</button></main></div>;
}
const portal = payload.portal || {};
const delivery = payload.delivery || {};
const files = payload.files || [];
const review = payload.review || { status: "pending", count: 0 };
const reviewStatusLabel = review.status === "approved" ? "已验收" : review.status === "changes_requested" ? "需修改" : "待客户确认";
return <div className="delivery-portal-shell">
<header className="delivery-portal-topbar">
<div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div>
<div className="delivery-portal-secure"><LockKeyhole size={14} />本地发行 · 受控访问</div>
</header>
<main className="delivery-portal-main">
<section className="delivery-portal-intro">
<span className="card-kicker">DELIVERY PORTAL</span>
<h1>{delivery.projectName || "项目交付资料"}</h1>
<p>项目方已发布一份可下载的交付版本,请使用下方文件入口获取本次交付资料。</p>
{portal.recipientName && <span className="delivery-portal-recipient">收件人:{portal.recipientName}</span>}
</section>
<section className="delivery-portal-summary">
<div><span>交付版本</span><strong>{delivery.version || "未命名版本"}</strong></div>
<div><span>发布时间</span><strong>{formatDate(delivery.publishedAt)}</strong></div>
<div><span>链接有效期</span><strong>{formatDate(portal.expiresAt)}</strong></div>
<div><span>剩余下载</span><strong>{portal.downloadsRemaining ?? 0} / {portal.maxDownloads ?? 0}</strong></div>
</section>
<section className="delivery-portal-files">
<div className="delivery-portal-section-heading"><div><span className="card-kicker">FILES</span><h2>交付文件</h2></div><span className="delivery-portal-count">{files.length} 个文件</span></div>
<div className="delivery-portal-file-list">
{files.map((file) => <article className="delivery-portal-file" key={file.kind}><div className="delivery-portal-file-icon"><Download size={17} /></div><div><strong>{file.label}</strong><span>{file.fileName}</span><small>下载后会记录一次交付访问</small></div><a className="primary delivery-portal-download" href={publicDeliveryFileUrl(token, file.kind)}><Download size={14} />下载</a></article>)}
</div>
</section>
<section className="delivery-portal-review">
<div className="delivery-portal-section-heading"><div><span className="card-kicker">CLIENT REVIEW</span><h2>客户验收</h2></div><span className={`delivery-review-status ${review.status}`}><MessageSquare size={13} />{reviewStatusLabel}</span></div>
{review.message && <div className="delivery-portal-review-current"><strong>{review.reviewerName || "客户联系人"}</strong><span>{formatDate(review.submittedAt)}</span><p>{review.message}</p></div>}
<div className="delivery-portal-review-form">
<div className="delivery-portal-review-fields"><label>联系人<input value={feedbackName} onChange={(event) => setFeedbackName(event.target.value)} placeholder="姓名 / 部门" /></label><label>邮箱<input type="email" value={feedbackEmail} onChange={(event) => setFeedbackEmail(event.target.value)} placeholder="用于项目方识别" /></label></div>
<label>反馈说明<textarea rows="4" value={feedbackMessage} onChange={(event) => setFeedbackMessage(event.target.value)} placeholder="如需修改,请写明具体镜头、时间点或交付问题;确认验收时可留空。" /></label>
{feedbackNotice && <div className="delivery-portal-review-notice"><CheckCircle2 size={14} />{feedbackNotice}</div>}
<div className="delivery-portal-review-actions"><button className="subtle" type="button" onClick={() => submitFeedback("changes_requested")} disabled={feedbackBusy}><Send size={14} />提交修改意见</button><button className="primary" type="button" onClick={() => submitFeedback("approved")} disabled={feedbackBusy}><CheckCircle2 size={14} />确认验收</button></div>
</div>
{review.count > 0 && <small className="delivery-portal-review-history">本链接已提交 {review.count} 次反馈,项目方可查看完整记录。</small>}
</section>
<footer className="delivery-portal-footer"><CheckCircle2 size={15} />发布版本:{delivery.version || "-"} · 已通过项目方交付审批</footer>
</main>
</div>;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
export function ScenePreview({ shot, active }) {
return (
<div className={`scene-preview ${active ? "active" : ""}`}>
<div className="rain"></div>
<div className="canopy"></div>
<div className="glass"></div>
<div className="street-tree"></div>
<div className="cones">
<span></span>
<span></span>
</div>
<div className="puddle"></div>
<div className="character chen">
<span></span>
</div>
<div className="character tang">
<span></span>
</div>
<div className="scene-caption">
<strong>{shot.id}</strong>
<span>{shot.title}</span>
</div>
</div>
);
}