Files
ttstd-infinite-canvas/web/src/lib/project-workbench.ts
ddcat666 d2fbc413b3 feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
2026-07-27 10:45:11 +08:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ProjectDetail, ProjectSummary, ProjectUnit } from "@/services/api/projects";
export type ProjectActionTone = "default" | "attention" | "danger";
export type ProjectWorkbenchAction = {
id: string;
title: string;
description: string;
href: string;
actionLabel: string;
tone: ProjectActionTone;
};
export type ProjectContinueTarget = {
href: string;
title: string;
context: string;
updatedAt: string;
};
export type ProjectStageCell = {
label: string;
state: "idle" | "active" | "attention" | "completed";
};
export type ProjectUnitStage = {
unit: ProjectUnit;
content: ProjectStageCell;
assets: ProjectStageCell;
storyboard: ProjectStageCell;
canvas: ProjectStageCell;
};
export function projectSummaryCompletion(summary: ProjectSummary) {
return summary.unitCount ? Math.round((summary.completedUnitCount / summary.unitCount) * 100) : 0;
}
export function projectSummaryStage(summary: ProjectSummary) {
if (summary.project.status === "archived") return { label: "已归档", detail: "可在项目设置中恢复" };
if (!summary.unitCount) return { label: "准备故事", detail: "还没有剧情章节" };
if (summary.completedUnitCount === summary.unitCount) return { label: "章节已完成", detail: "可继续检查镜头与交付结果" };
if (!summary.canvasCount) return { label: "组织章节", detail: "下一步可建立项目画布" };
if (!summary.assetCount) return { label: "准备资产", detail: "补充角色、场景或画风" };
return { label: "制作中", detail: `${summary.completedUnitCount}/${summary.unitCount} 章已完成` };
}
export function projectDetailStage(detail: ProjectDetail) {
if (detail.project.status === "archived") return { label: "已归档", detail: "恢复项目后可继续制作" };
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed").length;
if (failedSteps) return { label: "需要处理", detail: `${failedSteps} 个流程步骤失败` };
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length;
if (pendingCandidates) return { label: "资产确认", detail: `${pendingCandidates} 个候选待确认` };
if (!detail.units.length) return { label: "准备故事", detail: "添加或导入剧情章节" };
if (!detail.shots.length) return { label: "分镜准备", detail: "选择章节生成镜头" };
const runningSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "running" || step.status === "review").length;
if (runningSteps) return { label: "制作中", detail: `${runningSteps} 个流程步骤正在推进` };
const completedUnits = detail.units.filter((unit) => unit.status === "completed").length;
if (completedUnits === detail.units.length) return { label: "检查交付", detail: "章节已完成,可检查镜头与结果" };
return { label: "镜头制作", detail: `${detail.shots.length} 个镜头已建立` };
}
export function projectAttentionCount(detail: ProjectDetail) {
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length;
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed").length;
return pendingCandidates + failedSteps;
}
export function projectContinueTarget(detail: ProjectDetail): ProjectContinueTarget {
const latestCanvas = [...detail.canvases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
const latestUnit = [...detail.units].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
if (latestCanvas && (!latestUnit || latestCanvas.updatedAt >= latestUnit.updatedAt)) {
return {
href: `/canvas/${latestCanvas.id}`,
title: latestCanvas.title,
context: "继续编辑项目画布",
updatedAt: latestCanvas.updatedAt,
};
}
if (latestUnit) {
return {
href: `/projects/${detail.project.id}/chapters/${latestUnit.id}`,
title: latestUnit.title,
context: "继续处理剧情章节",
updatedAt: latestUnit.updatedAt,
};
}
return {
href: `/projects/${detail.project.id}/chapters`,
title: detail.project.name,
context: "从剧情章节开始",
updatedAt: detail.project.updatedAt,
};
}
// 项目概览必须把真实阻塞转成动作;没有事实依据时只提示通用的制作下一步。
export function projectNextActions(detail: ProjectDetail, limit = 4): ProjectWorkbenchAction[] {
const actions: ProjectWorkbenchAction[] = [];
const projectRoot = `/projects/${detail.project.id}`;
if (detail.project.status === "archived") {
return [{
id: "restore-project",
title: "项目已归档",
description: "恢复后才能创建画布和提交生成任务。",
href: `${projectRoot}/settings`,
actionLabel: "前往恢复",
tone: "attention",
}];
}
const failedSteps = detail.workflows.flatMap((workflow) => workflow.steps).filter((step) => step.status === "failed");
if (failedSteps.length) {
actions.push({
id: "failed-steps",
title: `处理 ${failedSteps.length} 个失败步骤`,
description: failedSteps[0].error?.trim() || "查看失败原因和输入后,仅重试受影响的任务。",
href: "/tasks?status=failed",
actionLabel: "查看失败任务",
tone: "danger",
});
}
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation");
if (pendingCandidates.length) {
const categories = new Set(pendingCandidates.map((candidate) => candidate.category));
actions.push({
id: "pending-assets",
title: `确认 ${pendingCandidates.length} 个资产候选`,
description: `${categories.size} 类角色、场景或制作资产需要确认后才能稳定复用。`,
href: `${projectRoot}/assets`,
actionLabel: "去确认",
tone: "attention",
});
}
if (!detail.units.length) {
actions.push({
id: "add-story",
title: "添加第一个剧情章节",
description: "导入小说、粘贴文本,或从空白章节开始。",
href: `${projectRoot}/chapters`,
actionLabel: "添加章节",
tone: "default",
});
} else {
const firstDraft = [...detail.units].sort(byPosition).find((unit) => unit.status === "draft");
if (firstDraft) {
actions.push({
id: `review-unit-${firstDraft.id}`,
title: `确认第 ${firstDraft.position + 1} 章内容`,
description: firstDraft.title,
href: `${projectRoot}/chapters/${firstDraft.id}`,
actionLabel: "继续处理",
tone: "default",
});
}
const unitsWithShots = new Set(detail.shots.map((shot) => shot.unitId).filter(Boolean));
const firstUnitWithoutShots = [...detail.units].sort(byPosition).find((unit) => unit.status !== "draft" && !unitsWithShots.has(unit.id));
if (firstUnitWithoutShots) {
actions.push({
id: `storyboard-unit-${firstUnitWithoutShots.id}`,
title: `为第 ${firstUnitWithoutShots.position + 1} 章建立分镜`,
description: `${firstUnitWithoutShots.title}还没有镜头,可先生成分镜草稿再逐项调整。`,
href: `${projectRoot}/chapters/${firstUnitWithoutShots.id}`,
actionLabel: "建立分镜",
tone: "default",
});
}
}
if (!detail.canvases.length && detail.units.length) {
actions.push({
id: "create-canvas",
title: "建立第一张项目画布",
description: "把章节、分镜和参考资产放进同一个制作空间。",
href: `${projectRoot}/canvases`,
actionLabel: "查看项目画布",
tone: "default",
});
}
if (!actions.length) {
const target = projectContinueTarget(detail);
actions.push({
id: "continue-project",
title: "继续最近的制作内容",
description: `${target.context}:${target.title}`,
href: target.href,
actionLabel: "继续创作",
tone: "default",
});
}
return actions.slice(0, limit);
}
export function projectUnitStages(detail: ProjectDetail, limit = 8): ProjectUnitStage[] {
const sortedUnits = [...detail.units].sort(byPosition).slice(0, limit);
return sortedUnits.map((unit) => {
const candidates = detail.assetCandidates.filter((candidate) => candidate.unitId === unit.id);
const pendingCandidates = candidates.filter((candidate) => candidate.status === "pending_confirmation").length;
const confirmedCandidates = candidates.filter((candidate) => candidate.status === "confirmed").length;
const shots = detail.shots.filter((shot) => shot.unitId === unit.id);
const canvasCount = new Set(detail.canvasUnitLinks.filter((link) => link.unitId === unit.id).map((link) => link.canvasId)).size;
return {
unit,
content: contentStage(unit),
assets: pendingCandidates
? { label: `${pendingCandidates} 待确认`, state: "attention" }
: confirmedCandidates
? { label: `${confirmedCandidates} 已确认`, state: "completed" }
: { label: "未识别", state: "idle" },
storyboard: shots.length
? { label: `${shots.length} 镜头`, state: shots.every((shot) => shot.status === "completed") ? "completed" : "active" }
: { label: "未开始", state: "idle" },
canvas: canvasCount
? { label: `${canvasCount} 张`, state: "active" }
: { label: "未关联", state: "idle" },
};
});
}
function contentStage(unit: ProjectUnit): ProjectStageCell {
if (unit.status === "completed") return { label: "已完成", state: "completed" };
if (unit.status === "ready") return { label: "待制作", state: "active" };
return { label: "草稿", state: "attention" };
}
function byPosition(left: ProjectUnit, right: ProjectUnit) {
return left.position - right.position;
}