refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态

This commit is contained in:
ddCat
2026-09-09 22:02:10 +08:00
parent bacd502f5c
commit b5f3413ded
298 changed files with 12681 additions and 26428 deletions

View File

@@ -1,4 +1,5 @@
import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -754,7 +755,7 @@ function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean;
setText(decodeNovelText(await file.arrayBuffer()));
};
return (
<Modal title={null} open={open} footer={null} destroyOnHidden onCancel={onClose} width={760} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
<AppModal flush title={null} open={open} footer={null} onCancel={onClose} width={760}>
<div className="flex min-h-[478px] flex-col">
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
<div className="grid min-h-[430px] flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_240px]">
@@ -770,7 +771,7 @@ function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean;
</div>
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
Check,
@@ -26,32 +26,21 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeTimelineProject } from "@/lib/timeline/timeline-tracks";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import type { TimelineClip, TimelineProject } from "@/types/timeline";
import type { TimelineProject } from "@/types/timeline";
const EDITOR_TIMELINE_KEY = "editor-timeline";
/** M2.4 演示种子:首次进入项目编辑器且无本地时间线时初始化一个可交互示例。
* M4 接入真实数据源(画布节点 / 后端项目时间线)后移除。 */
function createEditorSeed(projectId: string): TimelineProject {
const clips: TimelineClip[] = [
{ id: `${projectId}:demo-v1`, kind: "video", nodeId: "demo-node-a", trackId: "video-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000 },
{ id: `${projectId}:demo-v2`, kind: "video", nodeId: "demo-node-b", trackId: "video-1", startMs: 6000, durationMs: 3000, sourceStartMs: 1000, sourceDurationMs: 8000 },
{ id: `${projectId}:demo-a1`, kind: "audio", nodeId: "demo-node-a", trackId: "audio-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000, volume: 0.8 },
{ id: `${projectId}:demo-s1`, kind: "subtitle", nodeId: "demo-node-a", trackId: "subtitle-1", startMs: 500, durationMs: 2000, subtitleEntryIndex: 0, text: "示例字幕" },
];
return normalizeTimelineProject({
version: 2,
tracks: [
{ id: "video-1", kind: "video", label: "视频 1", order: 0 },
{ id: "audio-1", kind: "audio", label: "音频 1", order: 1 },
{ id: "subtitle-1", kind: "subtitle", label: "字幕 1", order: 2 },
],
clips,
durationMs: 9000,
});
/**
* 为尚未产生本地时间线的项目建立一个可编辑的初始时间线。
* 该时间线只写入当前用户与项目作用域的本地存储,不代表服务端已有成片数据。
*/
function createEmptyEditorTimeline(): TimelineProject {
// 没有真实素材时返回空时间线,避免把不存在的 demo node 当成可剪辑资产。
// 用户添加素材后由编辑器命令创建轨道和片段;空项目不应被伪造为已有成片。
return normalizeTimelineProject({ version: 2, tracks: [], clips: [], durationMs: 0 });
}
/** 插槽堆叠:渲染该区域权限通过的插槽贡献(fail-closed,M5.2);
/** 插槽堆叠:只渲染通过权限检查的插件贡献;无可用插件时显示明确的缺失能力提示。
* 缺权限插件不渲染,改为一行诊断提示;无贡献时显示空态(停用插件可见)。 */
function SlotStack({ slots, emptyHint }: { slots: EditorSlotRegistration[]; emptyHint: string }) {
const allowed = slots.filter((slot) => pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
@@ -254,7 +243,7 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
const [assets, setAssets] = useState<ProjectAsset[]>(detail.assets);
const workbenchRef = useRef<HTMLDivElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// AI 助手浮层(顶部工具按钮触发,M6.3;Esc / 点击遮罩关闭)。
// AI 助手浮层由顶栏按钮打开,支持 Esc 和遮罩点击关闭,避免长期遮挡编辑区。
const [aiOpen, setAiOpen] = useState(false);
useEffect(() => {
if (!aiOpen) return;
@@ -268,17 +257,37 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
// 底部时间线高度(px):分隔条拖拽调整(120–480)。
const [timelineH, setTimelineH] = useState(240);
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
const scope = getActiveUserScope();
const projectId = detail.project.id;
const assetOwnerKey = JSON.stringify([scope, projectId]);
const activeAssetOwnerKeyRef = useRef(assetOwnerKey);
const assetRefreshSequenceRef = useRef(0);
activeAssetOwnerKeyRef.current = assetOwnerKey;
const refreshAssets = async (): Promise<ProjectAsset[] | null> => {
const refreshAssets = useCallback(async (): Promise<ProjectAsset[] | null> => {
const requestedOwnerKey = assetOwnerKey;
const requestSequence = assetRefreshSequenceRef.current + 1;
assetRefreshSequenceRef.current = requestSequence;
try {
const list = (await listProjectAssets(projectId)).assets;
// 项目、账号或刷新序号变化都说明当前响应已经过期;旧响应不得覆盖新上下文的素材快照。
if (
activeAssetOwnerKeyRef.current !== requestedOwnerKey ||
assetRefreshSequenceRef.current !== requestSequence
) {
return null;
}
setAssets(list);
return list;
} catch {
// 刷新失败保留现有列表;调用方可据此决定是否重试
} catch (error) {
// 过期请求的错误不属于当前编辑器上下文,也不应产生误导性告警。
if (activeAssetOwnerKeyRef.current !== requestedOwnerKey) return null;
// 资产列表属于读展示路径:保留当前快照,避免网络抖动让编辑器清空素材。
// 但不能把快照当成同步成功;记录项目上下文,便于定位并在下次进入时重试。
console.warn("刷新项目素材列表失败,已保留当前快照", { projectId, scope, error });
return null;
}
};
}, [assetOwnerKey, projectId, scope]);
useEffect(() => {
const onFsChange = () => setIsFullscreen(document.fullscreenElement === workbenchRef.current);
@@ -287,59 +296,91 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void workbenchRef.current?.requestFullscreen().catch(() => {});
}
const operation = document.fullscreenElement
? document.exitFullscreen()
: workbenchRef.current?.requestFullscreen();
void operation?.catch((error) => {
console.warn("切换编辑器全屏状态失败", { projectId, error });
});
};
const scope = getActiveUserScope();
const projectId = detail.project.id;
const detailAssetsRef = useRef(detail.assets);
detailAssetsRef.current = detail.assets;
// 挂载/切换项目时以服务端为准同步一次资产:detail.assets 只是详情页快照,
// 上个会话导入但详情未携带(或导入后未刷新)的素材会在进入编辑器后补现。
// 挂载、切换项目或切换账号时先恢复详情快照,再以服务端列表校准。
// detail.assets 只用于切换瞬间占位;refreshAssets 内部负责丢弃过期和乱序响应。
useEffect(() => {
setAssets(detailAssetsRef.current);
void refreshAssets();
return () => {
// 使当前 owner 下仍在途的请求失效,避免组件卸载后提交 state。
assetRefreshSequenceRef.current += 1;
};
}, [assetOwnerKey, refreshAssets]);
const store = useMemo(
() =>
createEditorStore({
saveTimeline: async (project: TimelineProject) => {
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
},
}),
[projectId, scope],
);
// 进入编辑器时只加载当前用户和项目作用域的本地时间线;没有真实数据时保持空时间线,禁止注入虚构片段。
useEffect(() => {
let cancelled = false;
void (async () => {
const list = await refreshAssets();
if (!cancelled && list) setAssets(list);
})();
return () => {
cancelled = true;
};
}, [projectId]);
const store = useState(() =>
createEditorStore({
saveTimeline: async (project: TimelineProject) => {
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
},
}),
)[0];
// 进入编辑器时加载本地时间线;无则初始化演示种子(不触发保存,历史从该状态起步)。
useEffect(() => {
let cancelled = false;
(async () => {
const storage = localForageStorageForScope(scope);
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
const loaded = raw ? normalizeTimelineProject(JSON.parse(raw)) : createEditorSeed(projectId);
if (!cancelled) store.getState().load(loaded);
try {
const storage = localForageStorageForScope(scope);
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
let loaded = createEmptyEditorTimeline();
if (raw) {
try {
loaded = normalizeTimelineProject(JSON.parse(raw));
} catch (error) {
// 本地历史数据属于读展示路径:坏记录不能阻塞编辑器启动,
// 但必须记录项目和存储键,后续可按键清理或迁移,而不是把坏数据伪装成有效时间线。
console.warn("读取编辑器本地时间线失败,已使用空时间线", {
projectId,
storageKey: `${EDITOR_TIMELINE_KEY}:${projectId}`,
error,
});
}
}
if (!cancelled) store.getState().load(loaded);
} catch (error) {
// IndexedDB/localforage 不可用时不写入空时间线,避免覆盖可能仍可恢复的本地数据。
console.error("读取编辑器本地时间线存储失败,保留编辑器初始状态", { projectId, error });
}
})();
return () => {
cancelled = true;
// 卸载前冲刷尚未落盘的改动(如切页发生在防抖窗口内),避免最后几次操作丢失。
void store.getState().flushSave?.().catch(() => {});
// flushSave 失败时 editor-store 会保留 isDirty;这里必须留下错误证据,不能假装已保存。
const flushSave = store.getState().flushSave;
if (flushSave) {
void flushSave().catch((error) => {
console.error("编辑器卸载前保存时间线失败,待下次操作重试", { projectId, error });
});
}
};
}, [scope, projectId, store]);
// 画布产物自动同步:detail.shotArtifacts 中当前采用(storyboard/action_board/
// 分镜/导出成片,且 ready + 有资源)尚未作为素材进本项目时,自动
// linkProjectAsset(source=canvas) 并入素材库 —— 剪辑器“素材选项”直接可用画布产物。
// 后端幂等(已链接直接返回),失败静默,下次进入编辑器自动补同步;
// 已完成集合记录在 ref,避免同一会话重复请求。
// 后端幂等(已链接直接返回);单个失败不阻塞其余资源,但会记录告警,
// 下次进入编辑器时再次尝试补同步。
// 该集合只表示“当前用户、当前项目会话已处理过”,切换账号或项目时必须清空,避免跨作用域复用 resource ID。
const syncedCanvasResourcesRef = useRef<Set<string>>(new Set());
const syncedAssetOwnerKeyRef = useRef(assetOwnerKey);
useEffect(() => {
if (syncedAssetOwnerKeyRef.current === assetOwnerKey) return;
syncedAssetOwnerKeyRef.current = assetOwnerKey;
syncedCanvasResourcesRef.current.clear();
}, [assetOwnerKey]);
const canvasSyncKeys = detail.shotArtifacts
.filter(
(a): a is ShotArtifact & { resourceId: string } =>
@@ -365,26 +406,24 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
const resourceId = key.slice("resource:".length);
try {
await linkProjectAsset(projectId, { assetId: resourceId, category: "material", source: "canvas" });
// 请求返回时组件可能已经切换项目;旧请求不能把结果记入新项目的会话集合。
if (cancelled) break;
syncedCanvasResourcesRef.current.add(key);
} catch {
// 单个同步失败不阻塞其余;留待下次进入编辑器重试
} catch (error) {
// 单个资源同步失败不阻塞其他资源;失败项不加入已完成集合,
// 这样下次进入编辑器仍会重试,而不是把未同步伪装成成功。
console.warn("画布产物同步到项目素材失败", { projectId, resourceId, error });
}
}
if (!cancelled) {
// 直接拉取最新列表(setAssets 稳定)而非经 refreshAssets,
// 避免闭包依赖变化导致 effect 反复触发
try {
const list = (await listProjectAssets(projectId)).assets;
if (!cancelled) setAssets(list);
} catch {
// 刷新失败保留现有列表,下次进入编辑器再同步
}
// 统一经过带 owner 与请求序号校验的刷新入口,防止同步后的旧请求覆盖新项目快照。
await refreshAssets();
}
})();
return () => {
cancelled = true;
};
}, [canvasSyncKeys, projectId, assets]);
}, [assets, canvasSyncKeys, projectId, refreshAssets]);
return (
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>

View File

@@ -1,4 +1,5 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
@@ -43,7 +44,7 @@ export function chapterAssetsFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === PromptTemplateOperation.CharacterExtract) {
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
}
return parseChapterAssetBreakdown(result.text);
@@ -123,9 +124,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
projectId: input.projectId,
nodeId: `${operation}:${input.chapterId}`,
mode: "text",
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
prompt: promptTemplateTaskPlaceholder("章节角色、场景与道具提取"),
config: { ...input.config, model },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: PromptTemplateOperation.ChapterAssetsExtract, promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");

View File

@@ -1,4 +1,5 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
import { resolveStyleExecutionPlan, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
@@ -14,9 +15,9 @@ export async function generateCharacterTurnaround(input: { projectId: string; as
projectId: input.projectId,
nodeId: `character-turnaround:${input.assetId}`,
mode: "image",
prompt: "使用当前启用的角色三视图模板。",
prompt: promptTemplateTaskPlaceholder("角色三视图"),
config: generationConfig,
metadata: { operation: "character_turnaround", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
metadata: { operation: PromptTemplateOperation.CharacterTurnaround, promptTemplateOperation: PromptTemplateOperation.CharacterTurnaround, promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
});
}

View File

@@ -1,9 +1,10 @@
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Input, Modal } from "antd";
import { Input } from "antd";
import { BookOpenText, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { useNavigate } from "react-router";
import { PaginationBar } from "@/components/layout/workspace-page";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import type { ProjectUnit } from "@/services/api/projects";
type Props = {
@@ -58,7 +59,7 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
</button>
<button type="button" disabled={currentIndex >= orderedUnits.length - 1} onClick={() => goTo(orderedUnits[currentIndex + 1])} aria-label="下一章" title="下一章"><ChevronRight /></button>
</div>
<Modal open={open} footer={null} title={null} destroyOnHidden className="workspace-modal workspace-modal-wide workflow-chapter-modal" onCancel={() => setOpen(false)} styles={{ body: { padding: 0 } }}>
<AppModal flush open={open} footer={null} title={null} className="workspace-modal workspace-modal-wide workflow-chapter-modal" onCancel={() => setOpen(false)}>
<div className="workflow-chapter-modal-shell">
<header className="workflow-chapter-modal-head">
<div className="workflow-chapter-modal-title"><span><BookOpenText /></span><div><strong>定位章节</strong><p>快速搜索并跳转到需要制作的剧情章节</p></div></div>
@@ -77,7 +78,7 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
</div>
<footer className="workflow-chapter-modal-pagination"><PaginationBar alwaysShow current={safePage} pageSize={pageSize} total={filteredUnits.length} pageSizeOptions={[100, 200, 500]} itemLabel="章" onChange={(nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); }} /></footer>
</div>
</Modal>
</AppModal>
</>
);
}

View File

@@ -2,10 +2,11 @@ import { ImageSizePicker } from "@/components/image-size-picker";
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { App, Button, Form, Image, Input, InputNumber, Modal, Select } from "antd";
import { App, Button, Form, Image, Input, InputNumber, Select } from "antd";
import { SegmentedControl } from "@/components/ui/base/segmented-control";
import { EmptyState } from "@/components/ui/product/empty-state";
import { StatusBadge } from "@/components/ui/base/badges";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Maximize2, Play, Plus, RefreshCcw, Save, Search, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
@@ -576,18 +577,17 @@ export default function WorkflowProductionWorkbench(props: Props) {
</aside>
</div>
<Modal
<AppModal
flush
open={Boolean(imagePreviewArtifact?.resourceId)}
title={imagePreviewArtifact?.type === "action_board" ? "动作预演预览" : "分镜图预览"}
footer={null}
centered
destroyOnHidden
width="min(960px, calc(100vw - 32px))"
onCancel={() => setImagePreviewArtifact(null)}
styles={{ body: { padding: 0 } }}
>
{imagePreviewArtifact?.resourceId ? <img className={`workflow-image-preview-modal ${imagePreviewArtifact.type === "action_board" ? "grayscale" : ""}`} src={resourceFileUrl(imagePreviewArtifact.resourceId)} alt={imagePreviewArtifact.type === "action_board" ? "动作预演大图" : "分镜图大图"} /> : null}
</Modal>
</AppModal>
<ShotTimeline activeStage={activeStage} detail={detail} shots={shots} selectedShotId={selectedShot.id} submittingShotIds={submittingShotIds} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
</div>

View File

@@ -22,7 +22,41 @@ export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDeta
export function DeliveryStage({ detail, unitId }: { detail: ProjectDetail; unitId: string }) {
const shots = detail.shots.filter((item) => item.unitId === unitId);
const readyVideos = shots.filter((shot) => detail.shotArtifacts.some((item) => item.shotId === shot.id && item.type === "video" && item.selected && item.status === "ready"));
const readyVideos = shots.filter((shot) =>
detail.shotArtifacts.some((item) => item.shotId === shot.id && item.type === "video" && item.selected && item.status === "ready"),
);
const stale = detail.shotArtifacts.filter((item) => item.unitId === unitId && item.status === "stale").length;
return <section className="mx-auto max-w-5xl"><StageHeading eyebrow="06 / 交付与打包" title="交付前质量门禁" description="所有镜头视频就绪、过期产物清零后,再打包成片与生产资料。" /><div className="mt-6 grid gap-4 sm:grid-cols-3"><MetricCard icon={<Film className="size-5" />} label="视频已就绪" value={`${readyVideos.length} / ${shots.length}`} /><MetricCard icon={<Clock3 className="size-5" />} label="总时长" value={formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))} /><MetricCard icon={<Layers3 className="size-5" />} label="过期产物" value={String(stale)} /></div><div className="mt-5 border-y border-border/70 py-5"><div className="flex items-start gap-3"><PackageCheck className="mt-0.5 size-5 text-[var(--workspace-accent)]" /><div><h3 className="text-sm font-semibold">计划交付内容</h3><p className="mt-1 text-xs leading-5 text-foreground/48">成片 MP4、字幕 SRT、分镜 JSON/CSV、资产清单和生成参数 ZIP。</p></div></div><Button className="mt-5" disabled>生成交付包(后端打包尚未接入)</Button></div></section>;
return (
<section className="mx-auto max-w-5xl">
<StageHeading
eyebrow="06 / 交付与打包"
title="交付前质量门禁"
description="所有镜头视频就绪、过期产物清零后,再打包成片与生产资料。"
/>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<MetricCard icon={<Film className="size-5" />} label="视频已就绪" value={`${readyVideos.length} / ${shots.length}`} />
<MetricCard
icon={<Clock3 className="size-5" />}
label="总时长"
value={formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}
/>
<MetricCard icon={<Layers3 className="size-5" />} label="过期产物" value={String(stale)} />
</div>
<div className="mt-5 border-y border-border/70 py-5">
<div className="flex items-start gap-3">
<PackageCheck className="mt-0.5 size-5 text-[var(--workspace-accent)]" />
<div>
<h3 className="text-sm font-semibold">计划交付内容</h3>
<p className="mt-1 text-xs leading-5 text-foreground/48">
成片 MP4、字幕 SRT、分镜 JSON/CSV、资产清单和生成参数 ZIP。
</p>
</div>
</div>
<p className="mt-5 text-xs leading-5 text-foreground/48">
当前版本只提供交付前检查,不提供交付包生成入口;待后端打包任务、产物存储和下载权限具备后再开放导出。
</p>
</div>
</section>
);
}

View File

@@ -12,6 +12,7 @@ import { ModelPicker } from "@/components/model-picker";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
import { settingsPath } from "@/lib/settings-navigation";
import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
@@ -89,13 +90,26 @@ export default function ProjectsPage() {
try {
const project = await createUniqueProjectName(story, selectedStyle);
setGenerationStatus("AI 正在生成故事大纲与章节…");
const systemPrompt = `你是短剧编剧。根据用户的一句话故事,生成一部短剧的标题、一句话简介和 ${generateChapterCount} 个章节。生成要求:叙事采用${generateStructure}结构,每章约 ${generateWordCount} 字,使用${generatePerspective}视角,整体基调${generateTone},主要角色约 ${generateCharacterScale},章节篇幅${generateChapterLength}。只输出一个 JSON 对象,不要输出 markdown 代码块或其他文字。JSON 结构:{"title":"剧名","synopsis":"一句话简介","chapters":[{"title":"章节标题","content":"本章情节"}]}`;
const result = await runBackendGenerationTask({
projectId: project.project.id,
mode: "text",
prompt: story,
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel, systemPrompt },
metadata: { source: "project-story-generator", projectId: project.project.id },
prompt: promptTemplateTaskPlaceholder("短剧大纲"),
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel },
metadata: {
source: "project-story-generator",
projectId: project.project.id,
promptTemplateOperation: PromptTemplateOperation.ShortDramaOutline,
promptTemplateVariables: shortDramaOutlineVariables({
story,
chapterCount: generateChapterCount,
structure: generateStructure,
wordCount: generateWordCount,
perspective: generatePerspective,
tone: generateTone,
characterScale: generateCharacterScale,
chapterLength: generateChapterLength,
}),
},
onTextDelta: setGenerationPreview,
});
const answer = result.text || "";
@@ -309,26 +323,6 @@ export default function ProjectsPage() {
);
}
function parseGeneratedStory(answer: string) {
const cleaned = answer
.replace(/```json/gi, "")
.replace(/```/g, "")
.trim();
const match = cleaned.match(/\{[\s\S]*\}/);
const payload = match ? JSON.parse(match[0]) : {};
const title = String(payload.title || "").trim();
const synopsis = String(payload.synopsis || "").trim();
const chapters = Array.isArray(payload.chapters)
? payload.chapters
.map((chapter: unknown) => {
const item = typeof chapter === "object" && chapter ? chapter as Record<string, unknown> : {};
return { title: String(item.title || "").trim(), content: String(item.content || "").trim() };
})
.filter((chapter: { title: string; content: string }) => chapter.title && chapter.content)
: [];
return { title: title || storyTitleFromAnswer(answer), synopsis, chapters };
}
async function createUniqueProjectName(story: string, selectedStyle: CanvasStylePreset | null) {
const base = story.trim().slice(0, 24);
const buildInput = (name: string) => ({
@@ -352,11 +346,6 @@ async function createUniqueProjectName(story: string, selectedStyle: CanvasStyle
}
}
function storyTitleFromAnswer(answer: string) {
const line = answer.split(/\r?\n/).find((item) => item.trim());
return line ? line.trim().slice(0, 24) : "AI 生成短剧";
}
const generationSteps = [
{ label: "正在创建项目" },
{ label: "AI 正在生成故事大纲与章节" },