feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat
2026-08-29 23:34:46 +08:00
parent 4f07daae9e
commit 2f6832f99c
113 changed files with 7016 additions and 723 deletions

View File

@@ -4,6 +4,7 @@ import type { NodeGenerationContext } from "@/components/canvas/canvas-node-gene
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
import type { SkillRuntimeMetadata } from "@/services/skill-runtime";
import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
export type CanvasGenerationExecutorDependencies = {
@@ -32,6 +33,7 @@ export type CanvasGenerationExecution = CanvasGenerationExecutorDependencies & {
controller: AbortController;
editingTextNode: boolean;
styleMetadata: { styleProfileJson?: string; styleExecutionPlan?: StyleExecutionPlan };
skillMetadata: SkillRuntimeMetadata;
taskContext?: { conversationId?: string; messageId?: string };
retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string };
registerPendingNodeIds: (nodeIds: string[]) => void;

View File

@@ -35,6 +35,7 @@ export async function executeImageGeneration({
bindGenerationTask,
applyGenerationTaskResult,
styleMetadata,
skillMetadata,
taskContext,
retryContext,
showError,
@@ -96,6 +97,7 @@ export async function executeImageGeneration({
primaryImageId: undefined,
...generationMetadata,
...styleMetadata,
...skillMetadata,
imageBatchExpanded: count > 1 ? true : undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
@@ -116,6 +118,7 @@ export async function executeImageGeneration({
batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined,
...generationMetadata,
...styleMetadata,
...skillMetadata,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
@@ -176,6 +179,7 @@ export async function executeImageGeneration({
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
...styleMetadata,
...skillMetadata,
},
},
{

View File

@@ -28,6 +28,7 @@ export async function executeVideoGeneration({
applyGenerationTaskResult,
registerPendingNodeIds,
styleMetadata,
skillMetadata,
taskContext,
retryContext,
}: CanvasGenerationExecution) {
@@ -62,6 +63,7 @@ export async function executeVideoGeneration({
references: generationReferenceUrls(generationContext),
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
},
};
registerPendingNodeIds([videoId]);
@@ -111,6 +113,7 @@ export async function executeVideoGeneration({
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
},
},
{
@@ -139,6 +142,7 @@ export async function executeAudioGeneration({
applyGenerationTaskResult,
registerPendingNodeIds,
taskContext,
skillMetadata,
retryContext,
}: CanvasGenerationExecution) {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
@@ -152,7 +156,7 @@ export async function executeAudioGeneration({
position: isEmptyAudioNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y + ((sourceNode?.height || spec.height) - spec.height) / 2 },
width: isEmptyAudioNode ? sourceNode.width : spec.width,
height: isEmptyAudioNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig), ...skillMetadata },
};
registerPendingNodeIds([audioId]);
setNodes((current) =>
@@ -171,7 +175,7 @@ export async function executeAudioGeneration({
prompt: effectivePrompt,
config: generationConfig,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey },
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey, ...skillMetadata },
},
{
bindTask: (task) => bindGenerationTask(audioId, task),

View File

@@ -27,6 +27,7 @@ export async function executeTextGeneration({
applyGenerationTaskResult,
registerPendingNodeIds,
taskContext,
skillMetadata,
retryContext,
}: CanvasGenerationExecution) {
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
@@ -50,7 +51,7 @@ export async function executeTextGeneration({
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, ...skillMetadata },
}));
setNodes((current) => [...current.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((current) => [...current, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
@@ -71,7 +72,7 @@ export async function executeTextGeneration({
referenceImages: generationContext.referenceImages,
referenceVideos: generationContext.referenceVideos,
signal: controller.signal,
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions },
metadata: { sourceNodeId: nodeId, ...taskContext, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, ...skillMetadata },
},
{
bindTask: (task) => bindGenerationTask(targetNodeId, task),

View File

@@ -1643,6 +1643,7 @@ function InfiniteCanvasPage() {
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
useCanvasStoryboard({
projectId,
addedSkills,
nodesRef,
connectionsRef,
setNodes,

View File

@@ -8,11 +8,11 @@ import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce }
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
import { modelCompatibilityError, modelGroupReferenceLimits, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { skillRuntime } from "@/services/skill-runtime";
import type { GenerationTask } from "@/services/api/task-center";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store";
@@ -161,8 +161,14 @@ export function useCanvasGenerationExecutor({
return;
}
const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills);
let effectivePrompt = expandedPrompt.trim();
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"canvas">>>;
try {
skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawGenerationContext.prompt, skills: addedSkills });
} catch (error) {
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
return;
}
let effectivePrompt = skillExecution.prompt.trim();
let styleMetadata = {};
if (mode === "image") {
try {
@@ -278,6 +284,7 @@ export function useCanvasGenerationExecutor({
controller,
editingTextNode,
styleMetadata,
skillMetadata: skillExecution.metadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,

View File

@@ -23,13 +23,13 @@ import {
supportsVideoReferenceAudio,
} from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { skillRuntime, type SkillRuntimeMetadata } from "@/services/skill-runtime";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveImageUrl } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -122,7 +122,22 @@ export function useCanvasGenerationRetry({
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item)));
return;
}
const context = rawContext ? { ...rawContext, prompt: retryMode === "video" ? rawContext.prompt : expandSkillMentions(rawContext.prompt, addedSkills) } : null;
let skillMetadata: SkillRuntimeMetadata = {
skillIds: node.metadata?.skillIds || [],
skillVersions: node.metadata?.skillVersions || [],
skillFiles: node.metadata?.skillFiles || [],
};
let context = rawContext;
if (rawContext) {
try {
const skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawContext.prompt, skills: addedSkills });
context = { ...rawContext, prompt: skillExecution.prompt };
skillMetadata = skillExecution.metadata;
} catch (error) {
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
return;
}
}
const prompt = (context?.characterReferences.length ? context.prompt : savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
@@ -252,6 +267,7 @@ export function useCanvasGenerationRetry({
watermark: generationConfig.videoWatermark,
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
references: videoContext ? generationReferenceUrls(videoContext) : item.metadata?.references,
},
}
@@ -277,6 +293,7 @@ export function useCanvasGenerationRetry({
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
},
});
return;
@@ -319,7 +336,7 @@ export function useCanvasGenerationRetry({
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: providerPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit },
metadata: { ...item.metadata, prompt: providerPrompt, ...generationMetadata, ...styleMetadata, ...skillMetadata, emotionEdit: nextEmotionEdit },
}
: item,
),
@@ -333,7 +350,7 @@ export function useCanvasGenerationRetry({
referenceImages: [editReference, characterReference],
mask,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata },
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata, ...skillMetadata },
});
return;
}
@@ -355,7 +372,7 @@ export function useCanvasGenerationRetry({
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata },
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, ...skillMetadata },
}
: item,
),
@@ -375,6 +392,7 @@ export function useCanvasGenerationRetry({
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...styleMetadata,
...skillMetadata,
},
});
} catch (error) {

View File

@@ -24,7 +24,9 @@ import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboa
import { reconcileStoryboardTargetConnections, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer";
import { generationErrorMessage } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
import { skillRuntime } from "@/services/skill-runtime";
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import {
CanvasNodeType,
@@ -36,6 +38,7 @@ import {
type UseCanvasStoryboardOptions = {
projectId: string;
addedSkills: Skill[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -51,6 +54,7 @@ const NODE_STATUS_ERROR = "error" as const;
export function useCanvasStoryboard({
projectId,
addedSkills,
nodesRef,
connectionsRef,
setNodes,
@@ -135,13 +139,18 @@ export function useCanvasStoryboard({
navigateToSettings({ continueCreation: true });
return;
}
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined } } : node));
try {
const skillExecution = await skillRuntime.prepare({
profile: "shortDrama",
prompt: expandedPrompt,
skills: addedSkills,
});
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
const task = await createGenerationTask({
projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
prompt: expandedPrompt,
prompt: skillExecution.prompt,
model: generationConfig.model,
...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}),
input: {
@@ -152,7 +161,7 @@ export function useCanvasStoryboard({
shotDurationSeconds,
shotCount: requestedShotCount,
config: backendProviderConfig(generationConfig, "text"),
metadata: { nodeId },
metadata: { nodeId, ...skillExecution.metadata },
},
});
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_LOADING } } : node));
@@ -185,7 +194,7 @@ export function useCanvasStoryboard({
message.error(details);
return false;
}
}, [connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, projectId, setNodes]);
}, [addedSkills, connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, projectId, setNodes]);
const ensureScriptImageNodes = useCallback((nodeId: string, rowIds: string[]) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);

View File

@@ -92,8 +92,13 @@ export function useCanvasViewportController({
const handleCanvasDoubleClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (!fitCanvasSelection()) fitCanvasContent();
}, [fitCanvasContent, fitCanvasSelection]);
event.stopPropagation();
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setDialogNodeId(null);
setToolbarNodeId(null);
setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY), createOpen: true });
}, [screenToCanvas, setContextMenu, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const selectFocusedNode = useCallback((nodeId: string) => {
const selection = new Set([nodeId]);

View File

@@ -1,4 +1,4 @@
import { buildSkillMentionReferences, renderSkillPrompt } from "@/lib/canvas/canvas-skill-mentions";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
import { creationAttachmentKind, type CreationAttachment } from "./creation-assets";
@@ -78,10 +78,6 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen
const mediaMappings: string[] = [];
const attachmentPositions = new Map(attachments.map((attachment, index) => [attachment.id, index + 1]));
references.forEach((reference) => {
if (reference.kind === "skill" && reference.skill) {
contexts.push(renderSkillPrompt(reference.skill));
return;
}
if (reference.attachmentId) {
const position = attachmentPositions.get(reference.attachmentId);
const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : reference.kind === "text" ? "文件" : "图片";
@@ -94,12 +90,6 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen
return [...contexts, `【创作要求】\n${visiblePrompt}`].filter(Boolean).join("\n\n");
}
export function creationReferenceMetadata(references: CreationReference[]) {
return {
skillIds: references.flatMap((reference) => (reference.skill?.skill_id ? [reference.skill.skill_id] : [])),
};
}
function attachmentReference(attachment: CreationAttachment, index: number): CreationReference {
const kind = creationAttachmentKind(attachment);
const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "file" ? "文件" : "图片";

View File

@@ -43,7 +43,8 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
import { buildCreationMentionReferences, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
import { skillRuntime } from "@/services/skill-runtime";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
type CreationMode = "text" | "image" | "video";
@@ -386,7 +387,7 @@ export default function CreatePage() {
category: asset.category || "other",
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
asset,
searchText: asset.tags.join(" "),
searchText: (asset.tags || []).join(" "),
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
})),
...externalLibraryItems,
@@ -566,8 +567,22 @@ export default function CreatePage() {
audioCount: referenceAudios.length,
characterCount: 0,
});
const expandedPrompt = expandCreationPrompt(text, references, attachments);
const referenceMetadata = creationReferenceMetadata(references);
const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : []));
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"creation">>>;
try {
skillExecution = await skillRuntime.prepare({
profile: "creation",
prompt: expandCreationPrompt(text, references, attachments),
skills: skillReferences,
selectedSkillIds: skillReferences.map((skill) => skill.skill_id),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "技能上下文加载失败");
releaseRetryLock();
return;
}
const expandedPrompt = skillExecution.prompt;
const referenceMetadata = skillExecution.metadata;
followLatestMessageRef.current = true;
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings, ...retryContext });

View File

@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert, App, Button, Select, Tooltip } from "antd";
import { Alert, App, Button, Tooltip } from "antd";
import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutGrid, Plus, Settings2, type LucideIcon } from "lucide-react";
import { Link, Navigate, useNavigate, useParams } from "react-router";
@@ -7,7 +7,9 @@ import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { getProject, linkCanvasUnit } from "@/services/api/projects";
import { WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
import { useWorkspaceTopBarExtension } from "@/components/layout/workspace-top-bar-extension";
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
import type { ProjectDetail } from "@/services/api/projects";
import ProjectAssetsView from "./detail/assets";
import ProjectCanvasesView from "./detail/canvases";
@@ -15,6 +17,7 @@ import ProjectChaptersView from "./detail/chapters";
import ProjectOverviewView from "./detail/overview";
import ProjectSettingsView from "./detail/settings";
import ProjectWorkflowView from "./detail/workflow";
import { WorkflowChapterNavigator } from "./detail/workflow-chapter-navigator";
type DetailView = "overview" | "chapters" | "workflow" | "canvases" | "assets" | "settings";
@@ -33,7 +36,13 @@ export default function ProjectDetailPage() {
const queryClient = useQueryClient();
const { message } = App.useApp();
const activeView: DetailView = unitId || view === "workflow" ? "workflow" : chapterId ? "chapters" : views.some((item) => item.key === view) ? view as DetailView : "overview";
const detail = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId), refetchOnMount: "always" });
const detail = useQuery({
queryKey: ["project", projectId],
queryFn: () => getProject(projectId),
enabled: Boolean(projectId),
refetchOnMount: "always",
refetchInterval: (query) => (query.state.data?.tasks || []).some((task) => task.clientContext?.shotId && (task.status === "queued" || task.status === "running")) ? 2_000 : false,
});
const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); };
const createCanvas = () => {
if (detail.data?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; }
@@ -65,41 +74,27 @@ export default function ProjectDetailPage() {
navigate(`/canvas/${id}`);
}).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败"));
};
const chapterHref = detail.data ? projectChapterHref(detail.data.units, projectId, chapterId) : `/projects/${projectId}/chapters`;
const workflowHref = detail.data ? projectWorkflowHref(detail.data.units, projectId, unitId, stage) : `/projects/${projectId}/workflow`;
useWorkspaceTopBarExtension(detail.data ? (
<ProjectWorkspaceTopBar
detail={detail.data}
projectId={projectId}
activeView={activeView}
unitId={unitId}
stage={stage}
chapterHref={chapterHref}
workflowHref={workflowHref}
onCreateCanvas={createCanvas}
/>
) : null);
if (detail.isLoading) return <WorkspacePage><WorkspaceLoadingState label="正在打开项目工作台" detail="读取章节、画布、资产和当前进度" /></WorkspacePage>;
if (detail.isError || !detail.data) return <WorkspacePage><WorkspaceErrorState title="项目不可用" description="项目不存在、已被删除,或当前账号没有访问权限。" actionLabel="返回项目中心" onRetry={() => navigate("/projects")} /></WorkspacePage>;
if (!chapterId && !unitId && (!view || !views.some((item) => item.key === view))) return <Navigate to={`/projects/${projectId}/overview`} replace />;
if (activeView === "workflow" && !unitId && detail.data.units.length) return <Navigate to={`/projects/${projectId}/workflow/${detail.data.units.slice().sort((left, right) => left.position - right.position)[0].id}/video`} replace />;
const chapterHref = projectChapterHref(detail.data.units, projectId, chapterId);
const workflowHref = projectWorkflowHref(detail.data.units, projectId, unitId, stage);
return (
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
<div className="flex h-full min-h-0 flex-col">
<header className="project-workbench-header shrink-0 border-b border-border/60 bg-workspace-glass px-3 backdrop-blur-md sm:px-4 lg:px-5">
<div className="flex min-h-11 min-w-0 items-center gap-x-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5 lg:w-[250px] lg:flex-none xl:w-[290px]">
<button type="button" onClick={() => navigate("/projects")} className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
<div className="flex min-w-0 items-center gap-2">
<h1 className="min-w-0 truncate text-[var(--fs-body)] font-semibold text-foreground/90">{detail.data.project.name}</h1>
<span className={`size-1.5 shrink-0 rounded-full ${detail.data.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
</div>
</div>
<nav className="thin-scrollbar flex h-11 min-w-0 flex-1 items-center gap-0.5 overflow-x-auto lg:pl-3" aria-label="项目导航">
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[var(--fs-caption)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-3.5 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
</nav>
{activeView === "workflow" ? (
<Select
size="small"
className="hidden min-w-36 shrink-0 lg:block"
value={unitId || detail.data.units[0]?.id}
options={detail.data.units.slice().sort((left, right) => left.position - right.position).map((item, index) => ({ value: item.id, label: `${String(index + 1).padStart(2, "0")} · ${item.title}` }))}
onChange={(nextUnitId) => navigate(`/projects/${projectId}/workflow/${nextUnitId}/${stage || "video"}`)}
/>
) : (
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
)}
</div>
</header>
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className={activeView === "chapters" || activeView === "workflow" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
@@ -118,6 +113,28 @@ export default function ProjectDetailPage() {
);
}
function ProjectWorkspaceTopBar({ detail, projectId, activeView, unitId, stage, chapterHref, workflowHref, onCreateCanvas }: { detail: ProjectDetail; projectId: string; activeView: DetailView; unitId?: string; stage?: string; chapterHref: string; workflowHref: string; onCreateCanvas: () => void }) {
const navigate = useNavigate();
return (
<div className="project-workspace-topbar flex min-w-0 items-center gap-2">
<button type="button" onClick={() => navigate("/projects")} className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
<div className="hidden min-w-0 items-center gap-2 md:flex lg:w-44 xl:w-56">
<h1 className="min-w-0 truncate text-[var(--fs-caption)] font-semibold text-foreground/90">{detail.project.name}</h1>
<span className={`size-1.5 shrink-0 rounded-full ${detail.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 xl:inline">{detail.project.status === "archived" ? "已归档" : "进行中"}</span>
</div>
<nav className="thin-scrollbar flex h-11 min-w-0 flex-1 items-center gap-0.5 overflow-x-auto" aria-label="项目导航">
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2 text-[var(--fs-caption)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring xl:px-2.5 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:rounded-full after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-3.5 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="xl:hidden">{item.shortLabel}</span><span className="hidden xl:inline">{item.label}</span></Link>; })}
</nav>
{activeView === "workflow" ? (
<WorkflowChapterNavigator projectId={projectId} units={detail.units} unitId={unitId} stage={stage} />
) : (
<Tooltip title={activeView === "chapters" && detail.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-8 !shrink-0 !px-2 xl:!px-3" icon={<Plus className="size-3.5" />} onClick={onCreateCanvas} aria-label={activeView === "chapters" && detail.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden xl:inline">新建画布</span></Button></Tooltip>
)}
</div>
);
}
function projectWorkflowHref(units: Array<{ id: string; position: number }>, projectId: string, routeUnitId?: string, routeStage?: string) {
const targetId = [routeUnitId, sessionStorage.getItem(`project-active-chapter:${projectId}`) || ""].find((id) => id && units.some((unit) => unit.id === id)) || units.slice().sort((left, right) => left.position - right.position)[0]?.id;
return targetId ? `/projects/${projectId}/workflow/${targetId}/${routeStage || "video"}` : `/projects/${projectId}/workflow`;

View File

@@ -1,7 +1,7 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
@@ -12,7 +12,9 @@ import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { CHARACTER_VOICE_FORMAT_LABEL, CHARACTER_VOICE_UPLOAD_ACCEPT, characterVoiceFormatName, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import {
bindProjectCharacterVoice,
confirmProjectAssetCandidate,
@@ -22,7 +24,6 @@ import {
deleteProjectAssetFolder,
getProjectCharacter,
linkProjectAsset,
listVoiceProfiles,
moveProjectAsset,
replaceProjectCharacterRepresentations,
unbindProjectCharacterVoice,
@@ -41,6 +42,7 @@ import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type Ca
import { saveAs } from "file-saver";
import { ProjectCharacterCard } from "./project-character-card";
import { linkSelectedProjectAssets } from "./project-asset-linking";
import { generateCharacterTurnaround } from "./project-character-media";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
@@ -74,7 +76,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const externalAssetSources = useExternalAssetSources(addOpen || Boolean(imageAsset));
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
const [previewAsset, setPreviewAsset] = useState<ProjectAsset | null>(null);
const [voiceProfileId, setVoiceProfileId] = useState("");
const [voiceSample, setVoiceSample] = useState<{ resourceId: string; name: string; url: string } | null>(null);
const [voicePickerOpen, setVoicePickerOpen] = useState(false);
const [voiceInstructions, setVoiceInstructions] = useState("");
const [form] = Form.useForm<CharacterForm>();
@@ -89,7 +92,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
kindLabel: mediaLabel(asset.kind),
asset,
description: asset.note,
searchText: asset.tags.join(" "),
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
], [availableAssets, externalAssetSources.items]);
@@ -100,7 +103,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
category: asset.category || "other",
kindLabel: "图片",
asset,
searchText: asset.tags.join(" "),
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items.filter((item) => item.external?.item.kind === "image"),
], [externalAssetSources.items, imageAssets]);
@@ -125,7 +128,25 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
? detail.assets.length + pendingCandidates.length
: detail.assets.filter((asset) => asset.category === value).length + (value === "character" ? pendingCandidates.length : 0),
}));
const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) });
const audioPickerItems = useMemo<AssetLibraryPickerItem[]>(() => {
const localItems = personalAssets.flatMap((asset) => {
if (asset.kind !== "audio") return [];
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
if (!resourceId) return [];
return [{ id: asset.id, title: asset.title, category: "audio", kindLabel: `${characterVoiceFormatName(asset.data.mimeType)} 音频`, asset, description: asset.note || "素材库音频", searchText: (asset.tags || []).join(" ") }];
});
const projectItems = detail.assets.flatMap((asset) => {
if (asset.mediaType !== "audio") return [];
const resourceId = resourceIdFromStorageKey(asset.storageKey);
if (!resourceId || localItems.some((item) => item.id === asset.id)) return [];
return [{ id: asset.id, title: asset.title, category: "audio", kindLabel: "音频素材", description: asset.previewText || "项目音频素材", searchText: asset.title, disabledReason: undefined, folderId: asset.folderId, imageUrl: undefined }];
});
return [...localItems, ...projectItems];
}, [detail.assets, personalAssets]);
const audioResourceByItemId = useMemo(() => new Map([
...personalAssets.flatMap((asset) => asset.kind === "audio" ? [[asset.id, resourceIdFromStorageKey(asset.data.storageKey)] as const] : []),
...detail.assets.flatMap((asset) => asset.mediaType === "audio" ? [[asset.id, resourceIdFromStorageKey(asset.storageKey)] as const] : []),
].filter((entry): entry is readonly [string, string] => Boolean(entry[1]))), [detail.assets, personalAssets]);
const generatingAssets = useMutationState({
filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" },
select: (mutation) => mutation.state.variables as ProjectAsset | undefined,
@@ -134,7 +155,41 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const done = (content: string) => { refreshProject(); message.success(content); };
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory, nextFolderId }: { assetId: string; category: string; nextFolderId?: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory, folderId: nextFolderId }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
const addMutation = useMutation({
mutationFn: async ({ ids, nextFolderId }: { ids: string[]; nextFolderId?: string }) => {
const result = await linkSelectedProjectAssets(ids, async (id) => {
const pickerItem = availablePickerItems.find((item) => item.id === id);
if (pickerItem?.external) {
const imported = await externalAssetSources.importExternalAsset(pickerItem.external);
const assetId = addAsset(imported);
return linkProjectAsset(detail.project.id, {
assetId,
category: imported.kind === "entity" ? "character" : imported.category || "other",
folderId: nextFolderId,
});
}
const selected = pickerItem?.asset || useAssetStore.getState().assets.find((asset) => asset.id === id);
if (!selected) throw new Error("所选素材已不存在,请重新选择");
return linkProjectAsset(detail.project.id, {
assetId: selected.id,
category: selected.kind === "entity" ? "character" : selected.category || "other",
folderId: nextFolderId,
});
});
return {
assets: result.linked.map((item) => item.asset),
failedCount: result.failedCount,
};
},
onSuccess: ({ assets, failedCount }) => {
assets.forEach((asset) => updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }));
setAddOpen(false);
refreshProject();
if (failedCount) message.warning(`已引用 ${assets.length} 个素材,${failedCount} 个素材引用失败`);
else message.success(`已引用 ${assets.length} 个素材`);
},
onError: failed("资产引用失败"),
});
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
@@ -221,7 +276,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setImageAsset(null); done("三视图已绑定到新角色版本"); },
onError: failed("三视图绑定失败"),
});
const bindVoiceMutation = useMutation({ mutationFn: () => voiceAsset ? bindProjectCharacterVoice(detail.project.id, voiceAsset.id, { voiceProfileId, instructions: voiceInstructions }) : Promise.reject(new Error("未选择角色")), onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setVoiceAsset(null); done("声音素材已绑定到新角色版本"); }, onError: failed("声音绑定失败") });
const bindVoiceMutation = useMutation({ mutationFn: () => voiceAsset && voiceSample ? bindProjectCharacterVoice(detail.project.id, voiceAsset.id, { sampleResourceId: voiceSample.resourceId, voiceName: voiceSample.name, instructions: voiceInstructions }) : Promise.reject(new Error("请选择一份声音素材")), onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setVoiceAsset(null); setVoiceSample(null); done("声音素材已绑定到新角色版本"); }, onError: failed("声音绑定失败") });
const unbindVoiceMutation = useMutation({ mutationFn: () => voiceAsset ? unbindProjectCharacterVoice(detail.project.id, voiceAsset.id) : Promise.reject(new Error("未选择角色")), onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); setVoiceAsset(null); done("声音绑定已解除并生成新角色版本"); }, onError: failed("声音解绑失败") });
const openCharacterEditor = (asset: ProjectAsset | "new") => {
@@ -230,7 +285,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
form.setFieldsValue({ name: asset === "new" ? "" : asset.title, ...Object.fromEntries(characterFields.map(([key]) => [key, fieldValue(definition[key])])) } as CharacterForm);
};
const openImages = (asset: ProjectAsset) => setImageAsset(asset);
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
const openVoice = (asset: ProjectAsset) => { const sampleResourceId = asset.character?.voice?.profile.sampleResourceId || ""; setVoiceAsset(asset); setVoiceSample(sampleResourceId ? { resourceId: sampleResourceId, name: asset.character?.voice?.profile.name || "当前声音", url: resourceFileUrl(sampleResourceId) } : null); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
const openFolderEditor = (folder?: ProjectAssetFolder, parentId = folderId === ALL_FOLDERS ? "" : folderId) => {
setFolderEditor({ folder, parentId: folder?.parentId || parentId });
setFolderName(folder?.name || "");
@@ -337,23 +392,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
footerNote={externalAssetSources.error || undefined}
multiple={false}
multiple
title="素材库"
confirmLabel={() => "加入项目"}
confirmLabel={(count) => `加入项目${count ? `(${count})` : ""}`}
emptyTitle="没有可引用的素材"
emptyDescription="本地素材已全部加入项目,或切换到插件来源选择外部素材。"
onClose={() => setAddOpen(false)}
onConfirm={async (ids) => {
const selectedItem = availablePickerItems.find((item) => item.id === ids[0]);
if (selectedItem?.external) {
const imported = await externalAssetSources.importExternalAsset(selectedItem.external);
const assetId = addAsset(imported);
await addMutation.mutateAsync({ assetId, category: imported.kind === "entity" ? "character" : imported.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
return;
}
const selected = selectedItem?.asset || personalAssets.find((asset) => asset.id === ids[0]);
if (!selected) throw new Error("所选素材已不存在,请重新选择");
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
await addMutation.mutateAsync({ ids, nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
}}
/>
<ProjectAssetPreviewModal asset={previewAsset} personalAsset={previewAsset ? personalAssets.find((item) => item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} />
@@ -379,7 +425,48 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
await bindImagesMutation.mutateAsync(ids[0]);
}}
/>
<Modal className="workspace-modal workspace-modal-compact" title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between pt-1"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
<AssetLibraryPickerModal
open={voicePickerOpen}
items={audioPickerItems}
categoryLabels={{ all: "全部音频", audio: "声音素材" }}
initialCategory="audio"
multiple={false}
eyebrow="声音素材"
title="从素材库选择"
confirmLabel={() => "使用这份声音"}
emptyTitle="素材库还没有可用音频"
emptyDescription="可以从底部上传声音素材,上传后会自动选中。"
upload={{ accept: CHARACTER_VOICE_UPLOAD_ACCEPT, description: `支持 ${CHARACTER_VOICE_FORMAT_LABEL};上传后保存到素材库`, onUpload: async (files) => {
const ids: string[] = [];
for (const file of Array.from(files)) {
if (!isSupportedCharacterVoiceFile(file)) throw new Error(`声音素材支持 ${CHARACTER_VOICE_FORMAT_LABEL}`);
const uploaded = await uploadMediaFile(file, "character-voice");
const resourceId = resourceIdFromStorageKey(uploaded.storageKey);
if (!resourceId) throw new Error("声音上传未同步到服务端资源库,请检查后端连接");
ids.push(addAsset({ kind: "audio", title: characterVoiceTitleFromFileName(file.name), coverUrl: "", tags: ["角色声音"], status: "confirmed", source: "角色卡", data: { url: uploaded.url, storageKey: uploaded.storageKey, durationMs: uploaded.durationMs, bytes: uploaded.bytes, mimeType: uploaded.mimeType || file.type || "application/octet-stream" } }));
}
return ids;
} }}
onClose={() => setVoicePickerOpen(false)}
onConfirm={(ids) => {
const id = ids[0];
const resourceId = id ? audioResourceByItemId.get(id) : "";
if (!resourceId) throw new Error("所选声音素材尚未同步到服务端资源库");
const item = audioPickerItems.find((entry) => entry.id === id);
setVoiceSample({ resourceId, name: item?.title || "角色声音", url: resourceFileUrl(resourceId) });
setVoicePickerOpen(false);
}}
/>
<Modal className="workspace-modal workspace-modal-compact" title={`绑定声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceSample }} onCancel={() => { setVoiceAsset(null); setVoiceSample(null); }} onOk={() => bindVoiceMutation.mutate()}>
<div className="grid gap-3">
<div className="rounded-md border border-border/70 bg-foreground/[.025] p-3">
<div className="flex items-center justify-between gap-3"><div className="min-w-0"><div className="text-[var(--fs-label)] text-foreground/48">当前声音素材</div><div className="mt-1 truncate text-sm font-medium">{voiceSample?.name || "尚未选择声音素材"}</div></div><Button icon={<FolderOpen className="size-3.5" />} onClick={() => setVoicePickerOpen(true)}>选择或上传音频</Button></div>
{voiceSample ? <audio className="mt-3 w-full" src={voiceSample.url} controls preload="metadata" /> : <div className="mt-2 text-[var(--fs-tiny)] text-foreground/42">从素材库选择已有音频,或上传 {CHARACTER_VOICE_FORMAT_LABEL} 格式的声音样本。</div>}
</div>
<Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />
{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between pt-1"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import type { StoryboardRow } from "@/types/canvas";
import type { ProjectDetail, ShotRevisionInput } from "@/services/api/projects";
export type ChapterStoryboardAsset = {
id: string;
title: string;
type: "image" | "video" | "audio" | "character";
category?: string;
tags: string[];
prompt: string;
characterAssetId?: string;
characterVersionId?: string;
};
export type ChapterStoryboardCharacter = {
assetId?: string;
versionId?: string;
name: string;
definition: Record<string, unknown>;
};
export type ProjectShotReplacementInput = {
title: string;
description: string;
durationMs: number;
revision: ShotRevisionInput;
assetVersionIds: string[];
};
export type ChapterStoryboardReplaceImpact = {
shotCount: number;
revisionCount: number;
referenceCount: number;
artifactCount: number;
candidateCount: number;
};
export function chapterStoryboardCharacters(detail: ProjectDetail, unitId?: string): ChapterStoryboardCharacter[] {
const confirmed = detail.assets.flatMap((asset): ChapterStoryboardCharacter[] => {
const card = asset.character;
if (asset.category !== "character" || !card?.versionId) return [];
return [{ assetId: asset.id, versionId: card.versionId, name: asset.title, definition: card.definition || {} }];
});
const seenNames = new Set(confirmed.map((character) => normalizeCharacterName(character.name)));
const pending = detail.assetCandidates.flatMap((candidate): ChapterStoryboardCharacter[] => {
if (candidate.category !== "character" || candidate.status !== "pending_confirmation") return [];
if (unitId && candidate.unitId && candidate.unitId !== unitId) return [];
const name = candidate.name.trim();
const normalizedName = normalizeCharacterName(name);
if (!normalizedName || seenNames.has(normalizedName)) return [];
seenNames.add(normalizedName);
return [{ name, definition: candidateDefinition(candidate.detailsJson) }];
});
return [...confirmed, ...pending];
}
export function chapterStoryboardAssets(detail: ProjectDetail): ChapterStoryboardAsset[] {
return detail.assets.flatMap((asset): ChapterStoryboardAsset[] => {
const type = storyboardAssetType(asset.mediaType, asset.category);
if (!type || (type !== "character" && !asset.primaryVersionId)) return [];
const characterVersionId = asset.character?.versionId;
if (type === "character" && !characterVersionId) return [];
return [{
id: asset.id,
title: asset.title,
type,
category: asset.category || undefined,
tags: [],
prompt: assetPrompt(asset.previewText, asset.character?.definition, asset.title),
...(type === "character" ? { characterAssetId: asset.id, characterVersionId } : {}),
}];
}).slice(0, 60);
}
export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: ProjectDetail): ProjectShotReplacementInput[] {
const versionByAssetId = new Map((detail?.assets || []).flatMap((asset) => asset.primaryVersionId ? [[asset.id, asset.primaryVersionId] as const] : []));
return rows.map((row, index) => {
const shotNumber = index + 1;
const description = row.plotDescription.trim() || `镜头 ${shotNumber}`;
const durationMs = Math.max(1000, Math.round((Number(row.durationSeconds) || 1) * 1000));
return {
title: `SC.${String(shotNumber).padStart(2, "0")}`,
description,
durationMs,
assetVersionIds: storyboardRowAssetVersionIds(row, versionByAssetId),
revision: {
plotDescription: description,
action: joinStoryboardAction(row),
dialogue: row.dialogue.trim(),
shotSize: row.shotSize.trim(),
cameraAngle: row.camera.trim(),
cameraMovement: row.motion.trim(),
durationMs,
imagePrompt: row.imageGenerationPrompt.trim(),
videoPrompt: row.videoMotionPrompt.trim(),
negativePrompt: row.negativePrompt.trim(),
continuityNotes: row.continuityOut.trim(),
actionBeats: row.timeBeats.trim() ? [{ description: row.timeBeats.trim() }] : [],
},
};
});
}
export function chapterStoryboardReplaceImpact(detail: ProjectDetail, unitId: string): ChapterStoryboardReplaceImpact {
const shots = detail.shots.filter((shot) => shot.unitId === unitId);
const shotIds = new Set(shots.map((shot) => shot.id));
return {
shotCount: shots.length,
revisionCount: detail.shotRevisions.filter((revision) => shotIds.has(revision.shotId)).length,
referenceCount: detail.shotReferences.filter((reference) => shotIds.has(reference.shotId)).length,
artifactCount: detail.shotArtifacts.filter((artifact) => artifact.unitId === unitId).length,
candidateCount: detail.assetCandidates.filter((candidate) => Boolean(candidate.shotId && shotIds.has(candidate.shotId))).length,
};
}
function storyboardRowAssetVersionIds(row: StoryboardRow, versionByAssetId: Map<string, string>) {
const assetIds = [
...row.assetBindings.map((binding) => binding.nodeId),
...row.characters.flatMap((character) => character.characterAssetId ? [character.characterAssetId] : []),
];
return Array.from(new Set(assetIds)).flatMap((assetId) => {
const versionId = versionByAssetId.get(assetId);
return versionId ? [versionId] : [];
}).slice(0, 6);
}
function storyboardAssetType(mediaType: string, category: string): ChapterStoryboardAsset["type"] | null {
if (category === "character") return "character";
if (mediaType === "image" || mediaType === "video" || mediaType === "audio") return mediaType;
return null;
}
function assetPrompt(previewText: string | undefined, definition: Record<string, unknown> | undefined, title: string) {
const preview = previewText?.replace(/\s+/g, " ").trim();
if (preview) return preview.slice(0, 600);
if (definition && Object.keys(definition).length) return JSON.stringify(definition).slice(0, 600);
return title.trim();
}
function candidateDefinition(value: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(value || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
function normalizeCharacterName(value: string) {
return value.trim().toLocaleLowerCase();
}
function joinStoryboardAction(row: StoryboardRow) {
return [
labeledText("叙事意图", row.narrativeIntent),
labeledText("表演调度", row.performanceBlocking),
labeledText("时间节拍", row.timeBeats),
labeledText("情绪", row.emotion),
labeledText("光线与氛围", row.lightingAndAtmosphere),
labeledText("声音", row.audioEffects),
].filter(Boolean).join("\n");
}
function labeledText(label: string, value: string) {
const text = value.trim();
return text ? `${label}:${text}` : "";
}

View File

@@ -1,7 +1,7 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Tooltip } from "antd";
import { Alert, App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Tooltip } from "antd";
import CharacterCount from "@tiptap/extension-character-count";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
@@ -18,6 +18,7 @@ import {
Bold,
Check,
ChevronDown,
Clapperboard,
Code2,
Crosshair,
Eraser,
@@ -28,7 +29,6 @@ import {
Link2,
List,
ListOrdered,
LayoutGrid,
Minus,
MoreHorizontal,
MoveVertical,
@@ -47,11 +47,12 @@ import {
import { useNavigate, useParams } from "react-router";
import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/skill-runtime-picker";
import { ModelPicker } from "@/components/model-picker";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
createProjectAssetCandidates,
@@ -59,23 +60,26 @@ import {
deleteProjectUnit,
getProjectUnit,
importProjectUnits,
linkCanvasUnit,
replaceProjectUnitShots,
reorderProjectUnits,
updateProjectUnit,
type ProjectDetail,
type ProjectUnit,
} from "@/services/api/projects";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { extractChapterCharacters } from "./project-chapter-ai";
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 52;
const CHAPTER_ROW_HEIGHT = 62;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
type ChapterOperationKind = "characters" | "storyboard";
type ChapterOperation = { startedAt: number; taskId?: string };
export default function ProjectChaptersView({ detail, refreshProject, onCreateCanvas }: ProjectDetailViewProps) {
const { message } = App.useApp();
export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { chapterId = "" } = useParams();
@@ -91,12 +95,20 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
const [draftTitle, setDraftTitle] = useState("");
const [draftHtml, setDraftHtml] = useState("");
const [dirty, setDirty] = useState(false);
const [extractingCharacters, setExtractingCharacters] = useState(false);
const [importingCanvasId, setImportingCanvasId] = useState("");
const [characterExtractOpen, setCharacterExtractOpen] = useState(false);
const [storyboardOpen, setStoryboardOpen] = useState(false);
const [chapterOperations, setChapterOperations] = useState<Record<string, ChapterOperation>>({});
const [completedChapterOperations, setCompletedChapterOperations] = useState<Record<string, true>>({});
const [operationNow, setOperationNow] = useState(() => Date.now());
const [selectedTextModel, setSelectedTextModel] = useState("");
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const { skills: availableSkills, loading: skillsLoading } = useSkillRuntimeCatalog();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const localCanvases = useCanvasStore((state) => state.projects);
const listRef = useRef<HTMLDivElement>(null);
const locallyOwnedTaskIdsRef = useRef(new Set<string>());
const recoveredTaskIdsRef = useRef(new Set<string>());
const recoveringTaskIdsRef = useRef(new Set<string>());
const deferredSearchQuery = useDeferredValue(searchQuery.trim().toLocaleLowerCase("zh-CN"));
const orderedUnits = useMemo(() => {
const byId = new Map(detail.units.map((unit) => [unit.id, unit]));
@@ -108,14 +120,45 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
queryFn: () => getProjectUnit(detail.project.id, selectedId),
enabled: Boolean(selectedId),
});
const chapterTasksQuery = useQuery({
queryKey: ["project-chapter-generation-tasks", detail.project.id],
queryFn: () => listGenerationTasks(100, { projectId: detail.project.id }),
refetchInterval: (query) => query.state.data?.some((task) => {
const identity = chapterTaskIdentity(task);
return Boolean(identity && (task.status === "queued" || task.status === "running"));
}) ? 2_000 : false,
refetchOnWindowFocus: true,
});
const selectedUnit = selectedUnitQuery.data?.unit || selectedUnitSummary;
const chapterNumberById = useMemo(() => new Map(orderedUnits.map((unit, index) => [unit.id, index + 1])), [orderedUnits]);
const canvasCountByUnitId = useMemo(() => detail.canvasUnitLinks.reduce<Map<string, number>>((result, link) => result.set(link.unitId, (result.get(link.unitId) || 0) + 1), new Map()), [detail.canvasUnitLinks]);
const projectCanvasTargets = useMemo(() => {
const targets = new Map(detail.canvases.map((canvas) => [canvas.id, { id: canvas.id, title: canvas.title }]));
localCanvases.filter((canvas) => canvas.projectId === detail.project.id).forEach((canvas) => targets.set(canvas.id, { id: canvas.id, title: canvas.title }));
return Array.from(targets.values());
}, [detail.canvases, detail.project.id, localCanvases]);
const storyboardImpact = useMemo(() => chapterStoryboardReplaceImpact(detail, selectedUnit?.id || ""), [detail, selectedUnit?.id]);
const serverChapterOperations = useMemo(() => {
const operations = new Map<string, ChapterOperation>();
for (const task of chapterTasksQuery.data || []) {
if (task.status !== "queued" && task.status !== "running") continue;
const identity = chapterTaskIdentity(task);
if (!identity) continue;
const key = chapterOperationKey(identity.chapterId, identity.kind);
if (!operations.has(key)) operations.set(key, chapterOperationFromTask(task));
}
return operations;
}, [chapterTasksQuery.data]);
const serverCompletedOperations = useMemo(() => {
const latest = new Map<string, GenerationTask>();
for (const task of chapterTasksQuery.data || []) {
const identity = chapterTaskIdentity(task);
if (!identity) continue;
const key = chapterOperationKey(identity.chapterId, identity.kind);
if (!latest.has(key)) latest.set(key, task);
}
return new Set([...latest].filter(([, task]) => task.status === "succeeded").map(([key]) => key));
}, [chapterTasksQuery.data]);
const runningOperationCount = new Set([...Object.keys(chapterOperations), ...serverChapterOperations.keys()]).size;
const characterOperation = selectedUnit ? chapterOperations[chapterOperationKey(selectedUnit.id, "characters")] || serverChapterOperations.get(chapterOperationKey(selectedUnit.id, "characters")) : undefined;
const storyboardOperation = selectedUnit ? chapterOperations[chapterOperationKey(selectedUnit.id, "storyboard")] || serverChapterOperations.get(chapterOperationKey(selectedUnit.id, "storyboard")) : undefined;
const charactersGenerated = Boolean(selectedUnit && (completedChapterOperations[chapterOperationKey(selectedUnit.id, "characters")] || serverCompletedOperations.has(chapterOperationKey(selectedUnit.id, "characters")) || detail.assetCandidates.some((candidate) => candidate.unitId === selectedUnit.id && candidate.category === "character")));
const storyboardGenerated = Boolean(selectedUnit && (completedChapterOperations[chapterOperationKey(selectedUnit.id, "storyboard")] || serverCompletedOperations.has(chapterOperationKey(selectedUnit.id, "storyboard")) || storyboardImpact.shotCount > 0));
const visibleUnits = useMemo(() => {
if (!deferredSearchQuery) return orderedUnits;
const numericQuery = /^\d+$/.test(deferredSearchQuery) ? deferredSearchQuery.replace(/^0+/, "") || "0" : "";
@@ -153,6 +196,13 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
if (selectedId) sessionStorage.setItem(`project-active-chapter:${detail.project.id}`, selectedId);
}, [detail.project.id, selectedId]);
useEffect(() => {
if (!runningOperationCount) return;
setOperationNow(Date.now());
const timer = window.setInterval(() => setOperationNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [runningOperationCount]);
const saveMutation = useMutation({
mutationFn: () => selectedUnitQuery.data?.unit
? updateProjectUnit(detail.project.id, selectedUnitQuery.data.unit.id, {
@@ -231,15 +281,14 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
const wordCount = useMemo(() => editor?.storage.characterCount?.characters?.() || stripHtml(draftHtml).length || 0, [draftHtml, editor]);
const chapterCanvasCount = (unitId: string) => canvasCountByUnitId.get(unitId) || 0;
const chapterShotCount = (unitId: string) => detail.shots.filter((shot) => shot.unitId === unitId).length;
const chapterAnalysisInput = () => {
const chapterAnalysisInput = (textModel: string) => {
const unit = selectedUnitQuery.data?.unit;
if (!unit) throw new Error("章节正文尚未加载完成");
if (dirty) throw new Error("请先保存当前章节,再运行 AI 分析");
const sourceText = editor?.getText().trim() || stripHtml(unit.sourceText);
if (!sourceText) throw new Error("当前章节没有可分析的正文");
const textModel = effectiveConfig.textModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, textModel)) {
const config = { ...effectiveConfig, model: textModel, textModel };
if (!textModel || !isAiConfigReady(config, textModel)) {
navigateToSettings({ continueCreation: true });
return null;
}
@@ -250,70 +299,180 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
chapterTitle: unit.title,
sourceText,
projectStyle: resolveProjectCanvasStyle(detail.project.stylePresetId, detail.project.styleProfileJson)?.prompt || "",
config: effectiveConfig,
config,
};
};
const beginChapterOperation = (unitId: string, kind: ChapterOperationKind) => {
const key = chapterOperationKey(unitId, kind);
const startedAt = Date.now();
setOperationNow(startedAt);
setChapterOperations((current) => ({ ...current, [key]: { startedAt } }));
};
const updateChapterOperation = (unitId: string, kind: ChapterOperationKind, task: GenerationTask) => {
const key = chapterOperationKey(unitId, kind);
const taskStartedAt = Date.parse(task.startedAt || task.createdAt);
locallyOwnedTaskIdsRef.current.add(task.id);
setChapterOperations((current) => ({
...current,
[key]: {
startedAt: Number.isFinite(taskStartedAt) ? taskStartedAt : current[key]?.startedAt || Date.now(),
taskId: task.id,
},
}));
};
const finishChapterOperation = (unitId: string, kind: ChapterOperationKind) => {
const key = chapterOperationKey(unitId, kind);
setChapterOperations((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
};
const markChapterOperationCompleted = (unitId: string, kind: ChapterOperationKind) => {
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
};
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
const knownNames = new Set(detail.assetCandidates
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
.map((candidate) => normalizeCharacterName(candidate.name)));
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
if (fresh.length) {
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })));
}
markChapterOperationCompleted(unitId, "characters");
refreshProject();
return fresh.length;
};
const storeGeneratedStoryboard = async (unitId: string, rows: ReturnType<typeof chapterStoryboardFromGenerationTask>["rows"]) => {
const shots = storyboardRowsToProjectShots(rows, detail);
await replaceProjectUnitShots(detail.project.id, unitId, shots, detail.shots.filter((shot) => shot.unitId === unitId).map((shot) => shot.id));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["project", detail.project.id] }),
queryClient.invalidateQueries({ queryKey: ["projects"] }),
]);
markChapterOperationCompleted(unitId, "storyboard");
return shots.length;
};
const extractCharacters = async () => {
let operationUnitId = "";
try {
const input = chapterAnalysisInput();
const input = chapterAnalysisInput(selectedTextModel);
if (!input) return;
setExtractingCharacters(true);
const characters = await extractChapterCharacters(input);
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
const knownNames = new Set(detail.assetCandidates
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
.map((candidate) => normalizeCharacterName(candidate.name)));
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
if (!fresh.length) {
operationUnitId = input.chapterId;
setCharacterExtractOpen(false);
beginChapterOperation(operationUnitId, "characters");
message.info("角色提取任务已开始,可继续编辑或切换章节");
const characters = await extractChapterCharacters(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedCharacters(operationUnitId, characters);
if (!freshCount) {
message.info("本章角色已存在于待确认列表中");
return;
}
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId: input.chapterId, name: character.name, category: "character", details: { ...character } })));
refreshProject();
message.success(`已提取 ${fresh.length} 个角色,请到项目资产确认`);
message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
} finally {
setExtractingCharacters(false);
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
}
};
const importStoryboardToCanvas = async (targetCanvasId?: string) => {
if (!selectedUnit) return;
const shots = detail.shots.filter((shot) => shot.unitId === selectedUnit.id);
if (!shots.length) {
message.warning("本章没有可导入的历史分镜");
const createStoryboard = async () => {
const unit = selectedUnitQuery.data?.unit;
if (!unit) return message.warning("章节正文尚未加载完成");
if (dirty) return message.warning("请先保存当前章节,再生成分镜");
if (detail.project.status === "archived") return message.warning("项目已归档,请先在项目设置中恢复");
const sourceText = editor?.getText().trim() || stripHtml(unit.sourceText);
if (!sourceText) return message.warning("当前章节没有可用于分镜的正文");
const projectStyle = resolveProjectCanvasStyle(detail.project.stylePresetId, detail.project.styleProfileJson);
if (!projectStyle?.prompt.trim()) return message.warning("请先在项目设置中选择项目画风,再生成分镜");
const textModel = selectedTextModel;
const config = { ...effectiveConfig, model: textModel, textModel };
if (!isAiConfigReady(config, textModel)) {
navigateToSettings({ continueCreation: true });
return;
}
setImportingCanvasId(targetCanvasId || "new");
if (storyboardImpact.shotCount && !(await confirmStoryboardReplacement(storyboardImpact))) return;
setStoryboardOpen(false);
beginChapterOperation(unit.id, "storyboard");
message.info("分镜生成任务已开始,可继续编辑或切换章节");
try {
let canvasId = targetCanvasId || "";
if (canvasId) {
const canvas = useCanvasStore.getState().openProject(canvasId);
if (!canvas) throw new Error("目标画布尚未同步到本地,请刷新后重试");
const merged = upsertProjectChapterStoryboard(canvas.nodes, canvas.connections, { unit: selectedUnit, shots });
useCanvasStore.getState().updateProject(canvasId, { nodes: merged.nodes, connections: merged.connections, projectId: detail.project.id });
await saveRemoteUserDataNow();
} else {
const seed = upsertProjectChapterStoryboard([], [], { unit: selectedUnit, shots });
const created = await createCanvasProjectWithRemoteSync(`${selectedUnit.title} · 分镜画布`, detail.project.id, { nodes: seed.nodes, connections: seed.connections });
canvasId = created.id;
if (created.syncError) {
message.warning(created.syncError instanceof Error ? `分镜画布已保存在本地,章节关联稍后重试:${created.syncError.message}` : "分镜画布已保存在本地,章节关联稍后重试");
navigate(`/canvas/${canvasId}`);
return;
}
}
await linkCanvasUnit(detail.project.id, { canvasId, unitId: selectedUnit.id, role: "storyboard" });
refreshProject();
message.success(`已将 ${shots.length} 个分镜导入画布并关联本章`);
navigate(`/canvas/${canvasId}`);
const result = await generateChapterStoryboard({
projectId: detail.project.id,
chapterId: unit.id,
chapterTitle: unit.title,
sourceText,
projectStyle: {
presetId: projectStyle.id,
title: projectStyle.title,
prompt: projectStyle.prompt,
profileJson: detail.project.styleProfileJson,
},
characters: chapterStoryboardCharacters(detail, unit.id),
assets: chapterStoryboardAssets(detail),
config,
skills: availableSkills,
selectedSkillIds,
}, { onTaskUpdate: (task) => updateChapterOperation(unit.id, "storyboard", task) });
const shotCount = await storeGeneratedStoryboard(unit.id, result.rows);
message.success(result.skillCount ? `已生成 ${shotCount} 个分镜,并应用 ${result.skillCount} 个技能` : `已生成 ${shotCount} 个分镜`);
navigate(`/projects/${detail.project.id}/workflow/${unit.id}/storyboard`);
} catch (error) {
refreshProject();
message.error(error instanceof Error ? `分镜导入失败:${error.message}` : "分镜导入失败");
message.error(error instanceof Error ? `章节分镜生成失败:${error.message}` : "章节分镜生成失败");
} finally {
setImportingCanvasId("");
finishChapterOperation(unit.id, "storyboard");
}
};
const confirmStoryboardReplacement = (impact: ReturnType<typeof chapterStoryboardReplaceImpact>) => new Promise<boolean>((resolve) => {
modal.confirm({
title: `替换本章已有的 ${impact.shotCount} 个分镜?`,
content: (
<div className="space-y-2 text-sm leading-6 text-foreground/62">
<p>新分镜生成成功后,系统才会整体替换本章数据;如果生成失败,现有分镜不会受到影响。</p>
<p>替换会移除 {impact.shotCount} 个镜头、{impact.revisionCount} 个脚本版本、{impact.referenceCount} 个资产引用、{impact.artifactCount} 个生成产物{impact.candidateCount ? `及 ${impact.candidateCount} 个相关候选资产` : ""},此操作无法撤销。</p>
</div>
),
okText: "生成成功后替换",
okButtonProps: { danger: true },
cancelText: "保留现有分镜",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
useEffect(() => {
const latestByOperation = new Map<string, { task: GenerationTask; chapterId: string; kind: ChapterOperationKind }>();
for (const task of chapterTasksQuery.data || []) {
const identity = chapterTaskIdentity(task);
if (!identity) continue;
const key = chapterOperationKey(identity.chapterId, identity.kind);
if (!latestByOperation.has(key)) latestByOperation.set(key, { task, ...identity });
}
for (const { task, chapterId: taskChapterId, kind } of latestByOperation.values()) {
if (task.status !== "succeeded" || locallyOwnedTaskIdsRef.current.has(task.id) || recoveredTaskIdsRef.current.has(task.id) || recoveringTaskIdsRef.current.has(task.id)) continue;
if (chapterTaskResultAlreadyApplied(task, taskChapterId, kind, detail)) {
recoveredTaskIdsRef.current.add(task.id);
markChapterOperationCompleted(taskChapterId, kind);
continue;
}
recoveringTaskIdsRef.current.add(task.id);
void queryGenerationTask(task.id).then(async (completedTask) => {
if (kind === "characters") {
await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的角色提取结果");
} else {
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
message.success("已恢复刷新前完成的章节分镜");
}
recoveredTaskIdsRef.current.add(task.id);
}).catch((error) => {
message.error(error instanceof Error ? `任务结果恢复失败:${error.message}` : "任务结果恢复失败");
}).finally(() => recoveringTaskIdsRef.current.delete(task.id));
}
}, [chapterTasksQuery.data, detail]);
const selectChapter = (unitId: string) => {
if (unitId === selectedId) return;
if (dirty) { message.warning("请先保存当前章节,再切换章节"); return; }
@@ -390,17 +549,17 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
const chapterNumber = chapterNumberById.get(unit.id) || virtualItem.index + 1;
return (
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-surface-active" : "hover:bg-surface-hover"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-2 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/25 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-start gap-2 px-0 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
<span className={`w-8 shrink-0 pt-0.5 text-[var(--fs-tiny)] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
<span className="min-w-0 flex-1"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[var(--fs-tiny)] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[58px] items-start rounded-lg border px-0.5 transition-colors duration-100 ${unit.id === selectedUnit?.id ? "border-border/90 bg-surface-active" : "border-transparent hover:border-border/60 hover:bg-surface-hover"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-3.5 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/22 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
<span className={`grid h-8 min-w-8 shrink-0 place-items-center rounded-md px-1 text-[var(--fs-tiny)] font-semibold tabular-nums ${unit.id === selectedUnit?.id ? "bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]" : "bg-foreground/[.035] text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
<span className="min-w-0 flex-1"><span className={`block truncate text-[var(--fs-body)] ${unit.id === selectedUnit?.id ? "font-semibold text-foreground" : "font-medium text-foreground/68"}`}>{unit.title}</span><span className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{statusLabel(unit.status)}</span><span>·</span><span className="tabular-nums">{formatCount(unit.wordCount || 0)} 字</span><span>·</span><span className="tabular-nums">{chapterCanvasCount(unit.id)} 画布</span></span></span>
</button>
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-surface-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
<button type="button" className="mt-3.5 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-surface-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
</Dropdown>
<Popconfirm title="删除此章节?" description="章节内容及关联制作记录将被删除,已关联的画布不会删除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteMutation.isPending }} onConfirm={() => deleteMutation.mutate(unit.id)}>
<button type="button" className="mr-1 mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
<button type="button" className="mr-1 mt-3.5 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
</Popconfirm>
</div>
</div>
@@ -421,8 +580,8 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} loading={extractingCharacters} onClick={() => void extractCharacters()}>提取角色</Button>
{chapterShotCount(selectedUnit.id) ? <Dropdown trigger={["click"]} menu={{ items: [{ key: "new", icon: <Plus className="size-3.5" />, label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: <LayoutGrid className="size-3.5" />, label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}><Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} loading={Boolean(importingCanvasId)} disabled={extractingCharacters}>导入分镜</Button></Dropdown> : <Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || extractingCharacters} onClick={onCreateCanvas}>在画布中分镜</Button>}
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
</header>
@@ -435,6 +594,40 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
</section>
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
<Modal title="提取章节角色" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
<div className="grid gap-4">
<div className="rounded-lg border border-border/70 bg-foreground/[.018] px-3 py-2.5">
<div className="text-[var(--fs-tiny)] text-foreground/42">当前章节</div>
<div className="mt-1 truncate text-sm font-medium text-foreground/85">{selectedUnit?.title}</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/38">正文会交给本次选择的文本模型分析,提取结果进入“角色与资产”待确认列表。</div>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground/68">文本模型</span>
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
</label>
</div>
</Modal>
<Modal title="生成章节分镜" open={storyboardOpen} width={560} okText={storyboardImpact.shotCount ? "重新生成分镜" : "生成分镜"} cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setStoryboardOpen(false)} onOk={() => void createStoryboard()} styles={{ body: { paddingTop: 12 } }}>
<div className="grid gap-4">
<div className="rounded-lg border border-border/70 bg-foreground/[.018] px-3 py-2.5">
<div className="text-[var(--fs-tiny)] text-foreground/42">当前章节</div>
<div className="mt-1 truncate text-sm font-medium text-foreground/85">{selectedUnit?.title}</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/38">正文将作为分镜依据,生成结果会直接写入“分镜制作”。</div>
</div>
{storyboardImpact.shotCount ? <Alert type="warning" showIcon message={`本章已有 ${storyboardImpact.shotCount} 个分镜`} description="继续后会先生成新分镜;生成成功后,再按确认内容整体替换旧镜头及其关联数据。" /> : null}
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground/68">文本模型</span>
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于生成分镜的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
</label>
<div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground/68">分镜技能</span>
<SkillRuntimePicker profile="shortDrama" skills={availableSkills} loading={skillsLoading} value={selectedSkillIds} onChange={setSelectedSkillIds} placeholder="选择本次章节分镜使用的技能" />
</label>
<p className="mt-2 text-[var(--fs-tiny)] leading-5 text-foreground/42">可不选,最多 4 个。所选技能会在本次生成时由统一 Skill Runtime 按需读取,并记录实际使用的版本和文件。</p>
</div>
</div>
</Modal>
<Modal title="移动章节" open={Boolean(moveTargetId)} width={400} okText="移动" cancelText="取消" okButtonProps={{ disabled: !movePosition || movePosition < 1 || movePosition > orderedUnits.length, loading: reorderMutation.isPending }} onCancel={() => { setMoveTargetId(""); setMovePosition(null); }} onOk={moveChapterToPosition} styles={{ body: { paddingTop: 12 } }}>
<div className="text-xs leading-5 text-foreground/50">输入目标章节位置。适合上千章项目的长距离调整,移动后其他章节会自动顺延。</div>
<label className="mt-3 flex items-center gap-2 text-sm"><span className="shrink-0">移动到第</span><InputNumber min={1} max={orderedUnits.length} precision={0} value={movePosition} onChange={setMovePosition} className="min-w-0 flex-1" /><span className="shrink-0">章</span></label>
@@ -443,6 +636,31 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
);
}
function chapterOperationKey(unitId: string, kind: ChapterOperationKind) {
return `${unitId}:${kind}`;
}
function chapterOperationFromTask(task: GenerationTask): ChapterOperation {
const startedAt = Date.parse(task.startedAt || task.createdAt);
return { startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(), taskId: task.id };
}
function chapterTaskResultAlreadyApplied(task: GenerationTask, chapterId: string, kind: ChapterOperationKind, detail: ProjectDetail) {
const completedAt = Date.parse(task.completedAt || task.updatedAt);
if (!Number.isFinite(completedAt)) return false;
const updatedAt = kind === "characters"
? detail.assetCandidates.filter((candidate) => candidate.unitId === chapterId && candidate.category === "character").map((candidate) => Date.parse(candidate.updatedAt))
: detail.shots.filter((shot) => shot.unitId === chapterId).map((shot) => Date.parse(shot.updatedAt));
return updatedAt.some((timestamp) => Number.isFinite(timestamp) && timestamp >= completedAt);
}
function formatOperationElapsed(startedAt: number, now: number) {
const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1_000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = String(totalSeconds % 60).padStart(2, "0");
return `${minutes}分钟${seconds}秒`;
}
function EditorToolbar({ editor }: { editor: Editor | null }) {
const setLink = () => {
if (!editor) return;

View File

@@ -1,4 +1,4 @@
import { ArrowRight, CheckCircle2, CircleAlert, Clock3 } from "lucide-react";
import { ArrowRight, BookOpenText, CheckCircle2, CircleAlert, Clapperboard, Clock3, Film, PackageCheck, PlaySquare, UsersRound } from "lucide-react";
import { Link } from "react-router";
import { WorkspaceState } from "@/components/layout/workspace-state";
@@ -17,6 +17,25 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
const secondaryActions = actions.slice(1);
const continueTarget = projectContinueTarget(detail);
const unitStages = projectUnitStages(detail);
const firstUnitId = units.slice().sort((left, right) => left.position - right.position)[0]?.id;
const workflowHref = (targetStage: string) => firstUnitId ? `/projects/${project.id}/workflow/${firstUnitId}/${targetStage}` : `/projects/${project.id}/chapters`;
const readyArtifacts = detail.shotArtifacts.filter((artifact) => artifact.status === "ready");
const readyStoryboardCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "storyboard").map((artifact) => artifact.shotId)).size;
const readyPrevizCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "action_board").map((artifact) => artifact.shotId)).size;
const readyVideoCount = new Set(readyArtifacts.filter((artifact) => artifact.type === "video").map((artifact) => artifact.shotId)).size;
const productionSteps = [
{ id: "story", icon: BookOpenText, label: "剧情章节", description: "导入或编写正文,确认每章叙事目标", metric: `${units.length} 章 · ${formatCompactCount(units.reduce((total, unit) => total + (unit.wordCount || 0), 0))} 字`, href: `/projects/${project.id}/chapters`, complete: units.length > 0 },
{ id: "assets", icon: UsersRound, label: "角色与资产", description: "确认角色、场景、道具和项目画风", metric: `${detail.assets.length} 项资产`, href: `/projects/${project.id}/assets`, complete: detail.assets.length > 0 },
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${shots.length} 镜 · ${readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: shots.length > 0 },
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${readyPrevizCount}/${shots.length || 0} 镜`, href: workflowHref("previz"), complete: shots.length > 0 && readyPrevizCount === shots.length },
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${readyVideoCount}/${shots.length || 0} 镜`, href: workflowHref("video"), complete: shots.length > 0 && readyVideoCount === shots.length },
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: readyVideoCount && readyVideoCount === shots.length ? "可以交付" : `还差 ${Math.max(0, shots.length - readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: shots.length > 0 && readyVideoCount === shots.length },
];
const gaps = [
units.length === 0 ? "还没有剧情章节" : units.some((unit) => !unit.wordCount) ? `${units.filter((unit) => !unit.wordCount).length} 章还没有正文` : "章节正文已就绪",
detail.assetCandidates.some((candidate) => candidate.status === "pending_confirmation") ? `${detail.assetCandidates.filter((candidate) => candidate.status === "pending_confirmation").length} 项资产等待确认` : detail.assets.length ? "项目资产已建立" : "还没有角色与资产",
shots.length ? readyVideoCount === shots.length ? "所有镜头视频已生成" : `${shots.length - readyVideoCount} 个镜头尚未生成视频` : "还没有分镜镜头",
];
return (
<div className="space-y-8">
@@ -66,6 +85,14 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
</div>
</section>
<section className="project-standard-flow">
<div className="project-standard-flow-head"><div><span>标准制作流程</span><h2>从章节到可交付镜头</h2><p>先确认故事与资产,再逐镜完成画面、动作和视频。每个步骤都可直接进入对应工作区。</p></div><Link to={primaryAction.href}>继续当前任务<ArrowRight /></Link></div>
<div className="project-standard-flow-track">
{productionSteps.map((step, index) => { const Icon = step.icon; return <Link key={step.id} to={step.href} className={step.complete ? "is-complete" : ""}><span className="project-standard-flow-index">{step.complete ? <CheckCircle2 /> : index + 1}</span><span className="project-standard-flow-icon"><Icon /></span><strong>{step.label}</strong><p>{step.description}</p><em>{step.metric}</em><ArrowRight className="project-standard-flow-arrow" /></Link>; })}
</div>
<div className="project-standard-flow-footer"><div><strong>当前制作检查</strong>{gaps.map((gap, index) => <span key={gap}><i className={index === 2 && readyVideoCount !== shots.length ? "is-attention" : ""} />{gap}</span>)}</div><div><strong>快速入口</strong><Link to={`/projects/${project.id}/chapters`}>整理章节</Link><Link to={`/projects/${project.id}/assets`}>确认资产</Link><Link to={workflowHref("video")}>继续镜头制作</Link></div></div>
</section>
<section>
<div className="project-pipeline-head">
<div className="min-w-0">
@@ -94,6 +121,10 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
);
}
function formatCompactCount(value: number) {
return value >= 10_000 ? `${Math.round(value / 1_000) / 10} 万` : value.toLocaleString("zh-CN");
}
function ProjectFact({ label, value, attention = false }: { label: string; value: string; attention?: boolean }) {
return <div className="min-w-0"><dt>{label}</dt><dd className={attention ? "is-attention" : ""}>{value}</dd></div>;
}

View File

@@ -0,0 +1,13 @@
export async function linkSelectedProjectAssets<T>(ids: string[], linkOne: (id: string) => Promise<T>) {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
if (!uniqueIds.length) throw new Error("请选择要引用的素材");
const results = await Promise.allSettled(uniqueIds.map(linkOne));
const linked = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (!linked.length) {
const firstFailure = failures[0];
throw firstFailure instanceof Error ? firstFailure : new Error("素材引用失败,请重试");
}
return { linked, failedCount: failures.length };
}

View File

@@ -1,6 +1,13 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import type { AiConfig } from "@/stores/use-config-store";
import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import type { Skill } from "@/services/api/skills";
import { skillRuntime } from "@/services/skill-runtime";
import { logicalModelIDForConfig, type AiConfig } from "@/stores/use-config-store";
import type { ChapterStoryboardAsset, ChapterStoryboardCharacter } from "./chapter-storyboard-production";
type ChapterAnalysisInput = {
projectId: string;
@@ -12,17 +19,100 @@ type ChapterAnalysisInput = {
config: AiConfig;
};
export async function extractChapterCharacters(input: ChapterAnalysisInput): Promise<CharacterBreakdown[]> {
type ChapterTaskOptions = {
onTaskUpdate?: (task: GenerationTask) => void;
};
export type ChapterTaskKind = "characters" | "storyboard";
export function chapterTaskIdentity(task: GenerationTask): { chapterId: string; kind: ChapterTaskKind } | null {
const context = task.clientContext;
if (context?.chapterId && (context.chapterOperation === "characters" || context.chapterOperation === "storyboard")) {
return { chapterId: context.chapterId, kind: context.chapterOperation };
}
const metadata = generationTaskMetadata(task.inputJson);
const chapterId = typeof metadata.chapterId === "string" ? metadata.chapterId : "";
if (!chapterId) return null;
if (metadata.operation === "chapter_character_breakdown") return { chapterId, kind: "characters" };
if (metadata.source === "short-drama-chapter-storyboard") return { chapterId, kind: "storyboard" };
return null;
}
export function chapterCharactersFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
return parseCharacterBreakdown(result.text);
}
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
return storyboardRowsFromTask(task);
}
export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<CharacterBreakdown[]> {
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
项目名称: input.projectName,
章节名称: input.chapterTitle,
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
章节正文: input.sourceText,
});
}, options);
return parseCharacterBreakdown(result);
}
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, promptTemplateVariables: Record<string, string>) {
type ChapterStoryboardGenerationInput = {
projectId: string;
chapterId: string;
chapterTitle: string;
sourceText: string;
projectStyle: { presetId: string; title: string; prompt: string; profileJson?: string };
characters: ChapterStoryboardCharacter[];
assets: ChapterStoryboardAsset[];
config: AiConfig;
skills: Skill[];
selectedSkillIds: string[];
};
export async function generateChapterStoryboard(input: ChapterStoryboardGenerationInput, options?: ChapterTaskOptions) {
const model = input.config.textModel || input.config.model;
const config = { ...input.config, model };
const skillExecution = await skillRuntime.prepare({
profile: "shortDrama",
prompt: [
`章节:${input.chapterTitle}`,
input.sourceText,
"请将本章拆解为可直接进入短剧分镜制作的镜头脚本,保持剧情因果、人物关系、关键动作与台词完整。",
].join("\n\n"),
skills: input.skills,
selectedSkillIds: input.selectedSkillIds,
});
const task = await createGenerationTask({
projectId: input.projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
prompt: skillExecution.prompt,
model,
...(logicalModelIDForConfig(config) ? { logicalModelId: logicalModelIDForConfig(config) } : {}),
input: {
canvasAssets: input.assets,
requirements: "输出可直接写入分镜制作并继续生成分镜图、动作预演和镜头视频的分镜表。",
projectStyle: input.projectStyle,
characters: input.characters,
shotDurationSeconds: 0,
shotCount: 0,
config: backendProviderConfig(config, "text"),
metadata: {
domainProjectId: input.projectId,
chapterId: input.chapterId,
source: "short-drama-chapter-storyboard",
...skillExecution.metadata,
},
},
});
options?.onTaskUpdate?.(task);
const completed = await waitForGenerationTask(task.id, { initialTask: task, useTextEvents: true, onTaskUpdate: options?.onTaskUpdate });
return { ...storyboardRowsFromTask(completed), skillCount: skillExecution.selectedSkills.length };
}
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, promptTemplateVariables: Record<string, string>, options?: ChapterTaskOptions) {
const model = input.config.textModel || input.config.model;
const result = await runBackendCanvasGenerationTask({
projectId: input.projectId,
@@ -31,7 +121,18 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
prompt: "使用当前启用的角色卡提取模板。",
config: { ...input.config, model },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
return result.text;
}
function generationTaskMetadata(inputJson?: string): Record<string, unknown> {
if (!inputJson) return {};
try {
const input = JSON.parse(inputJson) as { metadata?: unknown };
return input.metadata && typeof input.metadata === "object" ? input.metadata as Record<string, unknown> : {};
} catch {
return {};
}
}

View File

@@ -1,11 +1,15 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation } from "@tanstack/react-query";
import { App, Button, Input, Modal, Select } from "antd";
import { Archive, Check, Eye, Palette, Pencil, Save, ShieldAlert } from "lucide-react";
import { Archive, Check, Eye, FolderOpen, Image as ImageIcon, Palette, Pencil, Save, ShieldAlert, Trash2 } from "lucide-react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { updateProject } from "@/services/api/projects";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import { resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
import type { ProjectDetailViewProps } from "./shared";
@@ -14,6 +18,8 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const { project } = detail;
const personalAssets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || "");
const [aspectRatio, setAspectRatio] = useState(project.aspectRatio);
@@ -24,6 +30,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const [styleEditorRequested, setStyleEditorRequested] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [coverPickerOpen, setCoverPickerOpen] = useState(false);
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); setStyleProfileJson(project.styleProfileJson || ""); }, [project]);
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || "") || styleProfileJson !== (project.styleProfileJson || ""), [aspectRatio, description, name, project, sourceType, stylePresetId, styleProfileJson]);
const selectedStyle = useMemo(() => resolveProjectCanvasStyle(stylePresetId, styleProfileJson), [stylePresetId, styleProfileJson]);
@@ -38,8 +45,43 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
video: resolveStyleExecutionPlan(styleProfile, { mode: "video", model: videoConfig.model, interfaceType: videoConfig.interfaceType || videoConfig.apiFormat }),
};
}, [effectiveConfig, styleProfile]);
const coverPickerItems = useMemo<AssetLibraryPickerItem[]>(() => {
const result: AssetLibraryPickerItem[] = [];
const seenResourceIds = new Set<string>();
for (const asset of personalAssets) {
if (asset.kind !== "image") continue;
const resourceId = resourceIdFromStorageKey(asset.data.storageKey);
if (!resourceId || seenResourceIds.has(resourceId)) continue;
seenResourceIds.add(resourceId);
result.push({ id: asset.id, title: asset.title, category: "image", kindLabel: "图片", asset, description: asset.note || "个人素材库", searchText: (asset.tags || []).join(" ") });
}
for (const asset of detail.assets) {
if (asset.mediaType !== "image") continue;
const resourceId = resourceIdFromStorageKey(asset.storageKey);
if (!resourceId || seenResourceIds.has(resourceId)) continue;
seenResourceIds.add(resourceId);
result.push({ id: `project:${asset.id}`, title: asset.title, category: "image", kindLabel: "项目图片", imageUrl: resourceFileUrl(resourceId), description: "项目素材库", searchText: asset.title });
}
if (project.coverResourceId && !seenResourceIds.has(project.coverResourceId)) {
result.unshift({ id: `current:${project.coverResourceId}`, title: "当前项目主图", category: "image", kindLabel: "当前主图", imageUrl: resourceFileUrl(project.coverResourceId) });
}
return result;
}, [detail.assets, personalAssets, project.coverResourceId]);
const coverResourceByItemId = useMemo(() => new Map(coverPickerItems.flatMap((item) => {
if (item.id.startsWith("current:")) return [[item.id, item.id.slice("current:".length)] as const];
if (item.id.startsWith("project:")) {
const asset = detail.assets.find((entry) => `project:${entry.id}` === item.id);
const resourceId = resourceIdFromStorageKey(asset?.storageKey);
return resourceId ? [[item.id, resourceId] as const] : [];
}
const asset = personalAssets.find((entry) => entry.id === item.id);
const resourceId = asset?.kind === "image" ? resourceIdFromStorageKey(asset.data.storageKey) : "";
return resourceId ? [[item.id, resourceId] as const] : [];
})), [coverPickerItems, detail.assets, personalAssets]);
const currentCoverItemId = coverPickerItems.find((item) => coverResourceByItemId.get(item.id) === project.coverResourceId)?.id;
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId, styleProfileJson }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") });
const coverMutation = useMutation({ mutationFn: (coverResourceId: string) => updateProject(project.id, { coverResourceId }), onSuccess: (_, coverResourceId) => { setCoverPickerOpen(false); refreshProject(); message.success(coverResourceId ? "项目主图已更新" : "项目主图已移除"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目主图更新失败") });
return (
<div>
@@ -55,7 +97,16 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
</div>
</section>
<section className="py-5">
<section className="border-t border-border/70 py-5">
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目主图</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">项目列表优先展示这张图片;未设置时使用项目画风示意图</p></div><span className="text-[var(--fs-label)] text-foreground/40">建议使用与项目画幅一致的图片</span></div>
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-surface-active p-3 sm:flex-row sm:items-center">
{project.coverResourceId ? <img src={resourceFileUrl(project.coverResourceId)} alt={`${project.name}项目主图`} className="aspect-video w-full shrink-0 rounded-md bg-foreground/5 object-cover sm:w-52" /> : <span className="grid aspect-video w-full shrink-0 place-items-center rounded-md bg-foreground/5 text-foreground/30 sm:w-52"><ImageIcon className="size-6" /></span>}
<div className="min-w-0 flex-1"><div className="text-sm font-medium">{project.coverResourceId ? "已设置项目主图" : "尚未设置项目主图"}</div><p className="mt-1 text-xs leading-5 text-foreground/48">从个人素材库或项目素材库选择,也可以在选择窗口中上传一张新图片。</p></div>
<div className="flex shrink-0 gap-2"><Button icon={<FolderOpen className="size-3.5" />} onClick={() => setCoverPickerOpen(true)}>{project.coverResourceId ? "替换主图" : "设置主图"}</Button>{project.coverResourceId ? <Button danger type="text" icon={<Trash2 className="size-3.5" />} loading={coverMutation.isPending} onClick={() => coverMutation.mutate("")}>移除</Button> : null}</div>
</div>
</section>
<section className="border-t border-border/70 py-5">
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">项目保存当前版本快照;修改“我的风格”不会自动改写历史项目</p></div>{styleProfile ? <span className="text-[var(--fs-label)] text-foreground/52">{styleProfile.source === "user" ? "来自我的风格" : styleProfile.source === "external" ? "外部导入" : "系统预设"}</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
<div className="flex flex-col gap-3 rounded-lg bg-surface-active p-3 lg:flex-row lg:items-center">
{selectedStyle ? <img src={selectedStyle.imageUrl} width="160" height="90" alt={`${selectedStyle.title}画风示意`} className="aspect-video w-40 shrink-0 rounded-md object-cover" /> : <span className="grid aspect-video w-40 shrink-0 place-items-center rounded-md bg-foreground/5 text-foreground/35"><Palette className="size-5" /></span>}
@@ -78,6 +129,39 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
</section>
<Modal className="workspace-modal workspace-modal-compact" title={project.status === "archived" ? "恢复项目" : "归档项目"} open={archiveOpen} okText={project.status === "archived" ? "确认恢复" : "确认归档"} cancelText="取消" okButtonProps={{ danger: project.status !== "archived", loading: archiveMutation.isPending }} onCancel={() => setArchiveOpen(false)} onOk={() => archiveMutation.mutate()} styles={{ body: { paddingTop: 12 } }}><p className="m-0 text-sm leading-6 text-foreground/65">{project.status === "archived" ? "恢复后项目会重新进入可编辑状态。" : "归档不会删除章节、画布或资产,画布文档仍可在创作画布中打开。"}</p></Modal>
<AssetLibraryPickerModal
open={coverPickerOpen}
items={coverPickerItems}
categoryLabels={{ all: "全部图片", image: "图片" }}
initialCategory="image"
initialSelectedIds={currentCoverItemId ? [currentCoverItemId] : []}
multiple={false}
eyebrow="项目设置"
title="选择项目主图"
confirmLabel={() => "设为项目主图"}
emptyTitle="素材库还没有图片"
emptyDescription="可以从底部上传一张新图片,上传后会自动选中。"
upload={{ accept: "image/png,image/jpeg,image/webp,image/avif", description: "支持 PNG、JPG、WebP、AVIF", onUpload: async (files) => {
const file = Array.from(files)[0];
if (!file?.type.startsWith("image/")) throw new Error("请选择图片文件");
const uploaded = await uploadImage(file);
const resourceId = resourceIdFromStorageKey(uploaded.storageKey);
if (!resourceId) throw new Error("图片上传未同步到服务端资源库,请检查后端连接");
const id = addAsset({ kind: "image", title: file.name.replace(/\.[^.]+$/, "") || "项目主图", coverUrl: uploaded.url, tags: ["项目主图"], status: "confirmed", source: "项目设置", data: { dataUrl: uploaded.url, storageKey: uploaded.storageKey, width: uploaded.width, height: uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType } });
return [id];
} }}
onClose={() => setCoverPickerOpen(false)}
onConfirm={async (ids) => {
const id = ids[0];
let resourceId = coverResourceByItemId.get(id);
if (!resourceId) {
const asset = useAssetStore.getState().assets.find((entry) => entry.id === id);
resourceId = asset?.kind === "image" ? resourceIdFromStorageKey(asset.data.storageKey) : "";
}
if (!resourceId) throw new Error("所选图片尚未同步到服务端资源库");
await coverMutation.mutateAsync(resourceId);
}}
/>
<CanvasStylePickerModal open={stylePickerOpen} value={stylePresetId} currentProfile={styleProfile} startInEditor={styleEditorRequested} onClose={() => { setStylePickerOpen(false); setStyleEditorRequested(false); }} onSelect={(preset) => { applyStyle(preset); setStylePickerOpen(false); setStyleEditorRequested(false); }} />
<CanvasStyleDetailModal open={Boolean(styleDetail)} preset={styleDetail} selected={styleDetail?.id === stylePresetId} onClose={() => setStyleDetail(null)} onSelect={(preset) => { applyStyle(preset); setStyleDetail(null); }} />
</div>

View File

@@ -0,0 +1,73 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Input, Modal } from "antd";
import { BookOpenText, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { useNavigate } from "react-router";
import type { ProjectUnit } from "@/services/api/projects";
type Props = {
projectId: string;
units: ProjectUnit[];
unitId?: string;
stage?: string;
};
export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Props) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN"));
const listRef = useRef<HTMLDivElement>(null);
const orderedUnits = useMemo(() => units.slice().sort((left, right) => left.position - right.position), [units]);
const currentIndex = Math.max(0, orderedUnits.findIndex((unit) => unit.id === unitId));
const current = orderedUnits[currentIndex];
const visibleUnits = useMemo(() => {
if (!deferredQuery) return orderedUnits;
const numericQuery = /^\d+$/.test(deferredQuery) ? deferredQuery.replace(/^0+/, "") || "0" : "";
return orderedUnits.filter((unit, index) => (numericQuery && String(index + 1).startsWith(numericQuery)) || unit.title.toLocaleLowerCase("zh-CN").includes(deferredQuery));
}, [deferredQuery, orderedUnits]);
const virtualizer = useVirtualizer({
count: visibleUnits.length,
getScrollElement: () => listRef.current,
estimateSize: () => 48,
getItemKey: (index) => visibleUnits[index]?.id || index,
overscan: 10,
});
useEffect(() => {
if (!open || deferredQuery) return;
const frame = window.requestAnimationFrame(() => virtualizer.scrollToIndex(currentIndex, { align: "center" }));
return () => window.cancelAnimationFrame(frame);
}, [currentIndex, deferredQuery, open, virtualizer]);
const goTo = (target?: ProjectUnit) => {
if (!target) return;
setOpen(false);
navigate(`/projects/${projectId}/workflow/${target.id}/${stage || "video"}`);
};
if (!current) return null;
return (
<>
<div className="workflow-chapter-navigator">
<button type="button" disabled={currentIndex === 0} onClick={() => goTo(orderedUnits[currentIndex - 1])} aria-label="上一章" title="上一章"><ChevronLeft /></button>
<button type="button" className="workflow-chapter-current" onClick={() => { setQuery(""); setOpen(true); }} aria-haspopup="dialog" aria-label={`定位章节,当前第 ${currentIndex + 1} 章`}>
<span>第 {currentIndex + 1} 章</span><strong>{current.title}</strong><em>{currentIndex + 1}/{orderedUnits.length}</em><ChevronDown />
</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} width={560} destroyOnHidden className="workspace-modal workflow-chapter-modal" onCancel={() => setOpen(false)} styles={{ body: { padding: 0 } }}>
<div className="workflow-chapter-modal-head"><span><BookOpenText /><strong>定位章节</strong><em>共 {orderedUnits.length.toLocaleString("zh-CN")} 章</em></span><Input autoFocus allowClear prefix={<Search className="size-3.5" />} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="输入章节序号或标题" /></div>
<div ref={listRef} className="workflow-chapter-list thin-scrollbar">
{visibleUnits.length ? <div className="relative" style={{ height: virtualizer.getTotalSize() }}>{virtualizer.getVirtualItems().map((virtualItem) => {
const unit = visibleUnits[virtualItem.index];
const index = orderedUnits.findIndex((item) => item.id === unit.id);
const selected = unit.id === current.id;
return <button key={unit.id} type="button" className={selected ? "is-active" : ""} style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }} onClick={() => goTo(unit)}><span>{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span><strong>{unit.title}</strong><em>{(unit.wordCount || 0).toLocaleString("zh-CN")} 字</em>{selected ? <small>当前</small> : null}</button>;
})}</div> : <div className="workflow-chapter-empty">没有匹配的章节</div>}
</div>
</Modal>
</>
);
}

View File

@@ -1,25 +1,31 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation } from "@tanstack/react-query";
import { App, Button, Empty, Form, Input, InputNumber, Segmented, Select, Tag } from "antd";
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, UsersRound, WandSparkles } from "lucide-react";
import { App, Button, Empty, Form, Image, Input, InputNumber, Segmented, Select, Tag } from "antd";
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { type GenerationTask } from "@/services/api/task-center";
import {
linkShotAsset,
createUnitWorkflow,
deleteProjectShot,
linkShotAsset,
registerProjectTaskOutput,
saveProjectShot,
unlinkShotAsset,
type ProjectAsset,
type ProjectDetail,
type ProjectShot,
type ShotAssetReference,
type ShotArtifact,
type ShotRevisionInput,
type WorkflowStep,
} from "@/services/api/projects";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { skillRuntime } from "@/services/skill-runtime";
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/skill-runtime-picker";
import {
ArtifactStatus,
@@ -30,6 +36,7 @@ import {
formatDuration,
type ShortDramaWorkflowStage,
} from "./workflow-shared";
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "./workflow-shot-references";
type ShotEditorValues = Omit<ShotRevisionInput, "durationMs"> & {
title: string;
@@ -68,11 +75,21 @@ export default function WorkflowProductionWorkbench(props: Props) {
const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest");
const [previewArtifactId, setPreviewArtifactId] = useState("");
const [editorDirty, setEditorDirty] = useState(false);
const [taskClock, setTaskClock] = useState(() => Date.now());
const shots = useMemo(() => (detail.shots || []).filter((item) => item.unitId === unitId).slice().sort((left, right) => left.position - right.position), [detail.shots, unitId]);
const shotIndex = selectedShot ? shots.findIndex((item) => item.id === selectedShot.id) : -1;
const revision = currentRevision(detail, selectedShot);
const artifactType = artifactTypeForStage(activeStage);
const artifacts = useMemo(() => selectedShot ? (detail.shotArtifacts || []).filter((item) => item.shotId === selectedShot.id && item.type === artifactType).slice().sort((left, right) => right.version - left.version) : [], [artifactType, detail.shotArtifacts, selectedShot]);
const shotTask = useMemo<GenerationTask | undefined>(() => {
return (detail.tasks || []).filter((task) => task.clientContext?.shotId === selectedShot?.id && task.clientContext?.artifactType === artifactType).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
}, [artifactType, detail.tasks, selectedShot?.id]);
useEffect(() => {
if (shotTask?.status !== "queued" && shotTask?.status !== "running") return;
const timer = window.setInterval(() => setTaskClock(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [shotTask?.status, shotTask?.id]);
const shotTaskElapsed = shotTask ? formatTaskElapsed(Date.parse(shotTask.startedAt || shotTask.createdAt), taskClock) : "";
const newestArtifact = artifacts.find((item) => item.selected) || artifacts[0];
const previewArtifact = artifacts.find((item) => item.id === previewArtifactId) || newestArtifact;
const modelOptions = (activeStage === "video" ? effectiveConfig.videoModels : effectiveConfig.imageModels) || [];
@@ -80,6 +97,10 @@ export default function WorkflowProductionWorkbench(props: Props) {
const [selectedModel, setSelectedModel] = useState(defaultModel || modelOptions[0] || "");
const [aspectRatio, setAspectRatio] = useState(detail.project.aspectRatio || "16:9");
const [resolution, setResolution] = useState(effectiveConfig.vquality || "720");
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const { skills: availableSkills, loading: skillsLoading } = useSkillRuntimeCatalog();
const shotAssetReferenceContext = useMemo(() => buildShotAssetReferenceContext(detail, selectedShot?.id || ""), [detail, selectedShot?.id]);
const referenceByVersionId = useMemo(() => new Map((detail.shotReferences || []).filter((reference) => reference.shotId === selectedShot?.id && reference.role === "reference" && reference.status === "linked").map((reference) => [reference.assetVersionId, reference])), [detail.shotReferences, selectedShot?.id]);
const modelSummary = selectedModel ? modelDisplayName(effectiveConfig, selectedModel) : "未选择模型";
const durationSummary = `${Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000))}s`;
const resolutionSummary = `${resolution.replace(/p$/i, "")}p`;
@@ -125,13 +146,25 @@ export default function WorkflowProductionWorkbench(props: Props) {
onError: (error) => message.error(error instanceof Error ? error.message : "镜头保存失败"),
});
const bindAsset = useMutation({
mutationFn: async (asset: ProjectAsset) => {
if (!selectedShot || !asset.primaryVersionId) throw new Error("该资产还没有可绑定版本");
const deleteShot = useMutation({
mutationFn: async ({ shotId }: { shotId: string; nextShotId: string }) => deleteProjectShot(projectId, shotId),
onSuccess: async (_result, { nextShotId }) => {
onSelectShot(nextShotId);
await onRefresh();
message.success("镜头已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "镜头删除失败"),
});
const changeAssetBinding = useMutation({
mutationFn: async ({ asset, reference }: { asset?: ProjectAsset; reference?: ShotAssetReference }) => {
if (!selectedShot) throw new Error("请先选择镜头");
if (reference) return unlinkShotAsset(projectId, selectedShot.id, reference.id);
if (!asset?.primaryVersionId) throw new Error("该资产还没有可绑定版本");
return linkShotAsset(projectId, selectedShot.id, { assetVersionId: asset.primaryVersionId, role: "reference" });
},
onSuccess: async () => { await onRefresh(); message.success("资产已绑定到当前镜头"); },
onError: (error) => message.error(error instanceof Error ? error.message : "资产绑定失败"),
onSuccess: async (_result, variables) => { await onRefresh(); message.success(variables.reference ? "已取消当前镜头的资产引用" : "资产已绑定到当前镜头"); },
onError: (error) => message.error(error instanceof Error ? error.message : "镜头资产更新失败"),
});
const generateArtifact = useMutation({
@@ -168,16 +201,23 @@ export default function WorkflowProductionWorkbench(props: Props) {
videoSeconds: String(Math.max(1, Math.round(values.durationSeconds))),
};
if (!isAiConfigReady(config, selectedModel)) throw new Error("当前模型渠道配置不完整,请先到设置中补齐");
const prompt = mode === "video"
const basePrompt = mode === "video"
? [values.videoPrompt || values.plotDescription, values.action, values.continuityNotes].filter(Boolean).join("\n")
: [values.imagePrompt || values.plotDescription, values.action, "黑白分镜草图,清晰动作节拍,电影构图"].filter(Boolean).join("\n");
const resolvedPrompt = resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext);
const skillExecution = await skillRuntime.prepare({
profile: "shortDrama",
prompt: resolvedPrompt,
skills: availableSkills,
selectedSkillIds,
});
let taskId = "";
const result = await runBackendGenerationTask({
projectId,
mode,
prompt,
prompt: skillExecution.prompt,
config,
referenceImages: shotReferenceImages(detail, selectedShot.id),
referenceImages: shotAssetReferenceContext.referenceImages,
metadata: {
workflowStepId: productionStep.id,
domainProjectId: projectId,
@@ -186,6 +226,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
artifactType,
role: "output",
source: "short-drama-workflow",
...skillExecution.metadata,
},
onTaskUpdate: (task) => { taskId = task.id; },
});
@@ -200,14 +241,13 @@ export default function WorkflowProductionWorkbench(props: Props) {
resourceId,
mediaType: mode,
role: "output",
metadataJson: JSON.stringify({ model: selectedModel, aspectRatio, resolution, durationSeconds: values.durationSeconds }),
metadataJson: JSON.stringify({ model: selectedModel, aspectRatio, resolution, durationSeconds: values.durationSeconds, ...skillExecution.metadata }),
});
},
onSuccess: async () => { setEditorDirty(false); await onRefresh(); message.success(`${productionStageCopy[activeStage as "storyboard" | "previz" | "video"].label}已生成`); },
onError: (error) => message.error(error instanceof Error ? error.message : "生成失败"),
});
const referencedVersionIds = new Set(selectedShot ? (detail.shotReferences || []).filter((item) => item.shotId === selectedShot.id).map((item) => item.assetVersionId) : []);
const stageCopy = productionStageCopy[activeStage as "storyboard" | "previz" | "video"];
if (!selectedShot) {
@@ -243,6 +283,21 @@ export default function WorkflowProductionWorkbench(props: Props) {
});
};
const requestDeleteShot = () => {
const nextShot = shots[shotIndex + 1] || shots[shotIndex - 1];
modal.confirm({
title: `删除镜头“${watchedTitle || selectedShot.title || "未命名镜头"}”?`,
content: editorDirty
? "该镜头的未保存修改、脚本版本、资产引用和生成产物都会被删除,且无法恢复。"
: "该镜头的脚本版本、资产引用和生成产物都会被删除,且无法恢复。",
okText: "删除镜头",
okButtonProps: { danger: true },
cancelText: "取消",
centered: true,
onOk: () => deleteShot.mutateAsync({ shotId: selectedShot.id, nextShotId: nextShot?.id || "" }),
});
};
const selectRelativeShot = (offset: number) => {
const next = shots[shotIndex + offset];
if (next) requestShotSelection(next.id);
@@ -260,7 +315,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
options={[{ value: "assets", label: "资产" }, { value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }]}
/>
<div className="workflow-library-scroll thin-scrollbar">
{leftTab === "assets" ? <AssetLibrary detail={detail} referencedVersionIds={referencedVersionIds} binding={bindAsset.isPending} onBind={(asset) => bindAsset.mutate(asset)} /> : null}
{leftTab === "assets" ? <AssetLibrary detail={detail} referenceByVersionId={referenceByVersionId} changing={changeAssetBinding.isPending} onToggle={(asset, reference) => changeAssetBinding.mutate({ asset, reference })} /> : null}
{leftTab === "episodes" ? <EpisodeLibrary detail={detail} activeUnitId={unitId} projectId={projectId} activeStage={activeStage} /> : null}
{leftTab === "shots" ? <ShotLibrary detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} /> : null}
</div>
@@ -279,8 +334,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
<div className="workflow-shot-form-scroll thin-scrollbar">
<div className="workflow-form-section-heading"><span>镜头脚本</span><small>先写清镜头里发生什么,再调整生成参数</small></div>
<Form.Item name="title" label="镜头名称" rules={[{ required: true, message: "请输入镜头名称" }]}><Input placeholder="用一句话概括这个镜头" /></Form.Item>
<Form.Item name="plotDescription" label="镜头画面" rules={[{ required: true, message: "请输入镜头画面" }]}><Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} placeholder="描述主体、场景、动作、构图、光线,以及观众在这一镜看到的信息" /></Form.Item>
<BoundAssets detail={detail} shotId={selectedShot.id} />
<Form.Item name="videoPrompt" label="视频提示词" rules={[{ required: true, message: "请输入视频提示词" }]}><ShotAssetMentionTextarea references={shotAssetReferenceContext.mentionReferences} /></Form.Item>
<BoundAssets detail={detail} shotId={selectedShot.id} changing={changeAssetBinding.isPending} onUnlink={(reference) => changeAssetBinding.mutate({ reference })} />
<div className="workflow-form-grid">
<Form.Item name="action" label="表演与动作"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="按动作节拍描述人物表演、走位和物体运动" /></Form.Item>
<Form.Item name="dialogue" label="对白 / 旁白"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="填写对白、旁白或需要保留的声音信息" /></Form.Item>
@@ -294,6 +349,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
<div className="workflow-settings-section">
<div className="workflow-settings-section-title">生成规格</div>
<Form.Item label="生成模型"><Select showSearch value={selectedModel || undefined} placeholder={activeStage === "video" ? "选择视频模型" : "选择图片模型"} onChange={setSelectedModel} options={modelOptions.map((value) => ({ value, label: modelDisplayName(effectiveConfig, value) }))} /></Form.Item>
<Form.Item label="技能库"><SkillRuntimePicker profile="shortDrama" skills={availableSkills} loading={skillsLoading} value={selectedSkillIds} onChange={setSelectedSkillIds} /></Form.Item>
<div className="workflow-form-grid is-three">
<Form.Item name="durationSeconds" label="镜头时长(秒)"><InputNumber className="w-full" min={0.5} max={60} step={0.5} /></Form.Item>
<Form.Item label="画幅"><Select value={aspectRatio} onChange={setAspectRatio} options={[detail.project.aspectRatio, "16:9", "9:16", "1:1"].filter((value, index, array) => value && array.indexOf(value) === index).map((value) => ({ value, label: value }))} /></Form.Item>
@@ -317,15 +373,15 @@ export default function WorkflowProductionWorkbench(props: Props) {
summary={<span>提示词 · 排除内容 · 接戏</span>}
>
<div className="workflow-form-grid">
<Form.Item name="plotDescription" label="镜头画面" rules={[{ required: true, message: "请输入镜头画面" }]}><ShotAssetMentionTextarea variant="scene" references={shotAssetReferenceContext.mentionReferences} /></Form.Item>
<Form.Item name="imagePrompt" label="画面提示词"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="留空时根据镜头画面自动生成" /></Form.Item>
<Form.Item name="videoPrompt" label="动态提示词"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="补充动作节奏、运镜变化和动态细节" /></Form.Item>
<Form.Item name="negativePrompt" label="排除内容"><Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="填写不希望出现的元素、动作或画面问题" /></Form.Item>
<Form.Item name="continuityNotes" label="接戏备注"><Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="记录人物位置、朝向、服装、道具及前后镜延续关系" /></Form.Item>
</div>
</WorkflowDisclosure>
</div>
<footer className="workflow-editor-actions">
<div className="flex items-center gap-2"><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={generateArtifact.isPending} onClick={() => generateArtifact.mutate()}>{stageCopy.action}</Button></div>
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || generateArtifact.isPending || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={generateArtifact.isPending || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => generateArtifact.mutate()}>{shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
</footer>
</Form>
</section>
@@ -354,11 +410,16 @@ export default function WorkflowProductionWorkbench(props: Props) {
</aside>
</div>
<ShotTimeline detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
<ShotTimeline activeStage={activeStage} detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
</div>
);
}
function formatTaskElapsed(startedAt: number, now: number) {
const totalSeconds = Math.max(0, Math.floor((now - (Number.isFinite(startedAt) ? startedAt : now)) / 1_000));
return `${Math.floor(totalSeconds / 60)}分钟${String(totalSeconds % 60).padStart(2, "0")}秒`;
}
function WorkflowDisclosure({ icon, title, description, summary, className = "", children }: { icon: ReactNode; title: string; description: string; summary: ReactNode; className?: string; children: ReactNode }) {
return (
<details className={`workflow-disclosure ${className}`}>
@@ -371,14 +432,32 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "",
);
}
function AssetLibrary({ detail, referencedVersionIds, binding, onBind }: { detail: ProjectDetail; referencedVersionIds: Set<string>; binding: boolean; onBind: (asset: ProjectAsset) => void }) {
function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map<string, ShotAssetReference>; changing: boolean; onToggle: (asset: ProjectAsset, reference?: ShotAssetReference) => void }) {
const groups = useMemo(() => {
const map = new Map<string, ProjectAsset[]>();
detail.assets.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset]));
return Array.from(map.entries());
}, [detail.assets]);
if (!groups.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />;
return <div className="workflow-asset-groups">{groups.map(([category, assets]) => <section key={category}><h3>{assetCategoryLabel(category)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const active = Boolean(asset.primaryVersionId && referencedVersionIds.has(asset.primaryVersionId)); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={binding || !asset.primaryVersionId || active} onClick={() => onBind(asset)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定当前镜头" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>)}</div>;
return <div className="workflow-asset-groups">{groups.map(([category, assets]) => <section key={category}><h3>{assetCategoryLabel(category)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>)}</div>;
}
function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType<typeof buildShotAssetReferenceContext>["mentionReferences"]; variant?: "scene" | "motion" }) {
const isScene = variant === "scene";
return (
<CanvasResourceMentionTextarea
value={value}
references={references}
onChange={onChange}
sendOnEnter={false}
containerClassName={`workflow-shot-mention-container ${isScene ? "is-scene" : ""}`}
className={`thin-scrollbar workflow-shot-mention-editor ${isScene ? "is-scene" : ""}`}
placeholder={isScene
? references.length ? "描述主体、场景、动作、构图与光线;输入 @ 可引用已绑定资产" : "描述主体、场景、动作、构图与光线;先绑定资产后可用 @ 引用"
: references.length ? "补充动作节奏、运镜变化和动态细节;输入 @ 可引用已绑定资产" : "补充动作节奏、运镜变化和动态细节;绑定资产后可用 @ 引用"}
aria-label={isScene ? "镜头画面,可使用 @ 引用已绑定资产" : "视频提示词,可使用 @ 引用已绑定资产"}
/>
);
}
function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { detail: ProjectDetail; activeUnitId: string; projectId: string; activeStage: ShortDramaWorkflowStage }) {
@@ -389,18 +468,26 @@ function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail:
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{String(index + 1).padStart(2, "0")}</span><span className="min-w-0 flex-1"><strong>{shot.title}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
}
function BoundAssets({ detail, shotId }: { detail: ProjectDetail; shotId: string }) {
function BoundAssets({ detail, shotId, changing, onUnlink }: { detail: ProjectDetail; shotId: string; changing: boolean; onUnlink: (reference: ShotAssetReference) => void }) {
const references = (detail.shotReferences || []).filter((item) => item.shotId === shotId);
const assetByVersionId = useMemo(() => new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset])), [detail.assets]);
return (
<div className="workflow-bound-assets">
<div className="workflow-bound-assets-heading"><span className="workflow-field-label">镜头资产</span><small>{references.length ? `已绑定 ${references.length} 项` : "从左侧资产栏点击绑定"}</small></div>
<div className="workflow-bound-assets-content">
{references.length ? references.map((reference) => {
const asset = assetByVersionId.get(reference.assetVersionId);
return <span key={reference.id} className="workflow-bound-asset-chip"><em>{asset ? assetCategoryLabel(asset.category) : "历史"}</em><span>{asset?.title || "历史资产版本"}</span></span>;
}) : <span>尚未绑定角色、场景或道具</span>}
</div>
<Image.PreviewGroup>
<div className="workflow-bound-assets-content">
{references.length ? references.map((reference) => {
const asset = assetByVersionId.get(reference.assetVersionId);
const title = asset?.title || "历史资产版本";
const previewUrl = asset ? assetPreviewUrl(asset) : "";
return <div key={reference.id} className="workflow-bound-asset-chip">
<span className="workflow-bound-asset-preview">{previewUrl ? <Image src={previewUrl} alt={`${title}预览`} width={40} height={40} loading="lazy" preview={{ mask: "预览" }} /> : <Box aria-hidden />}</span>
<span className="workflow-bound-asset-copy"><em>{asset ? assetCategoryLabel(asset.category) : "历史"}</em><strong title={title}>{title}</strong></span>
<button type="button" disabled={changing} aria-label={`取消引用 ${title}`} onClick={() => onUnlink(reference)}><X aria-hidden /></button>
</div>;
}) : <span>尚未绑定角色、场景或道具</span>}
</div>
</Image.PreviewGroup>
</div>
);
}
@@ -417,16 +504,31 @@ function ArtifactHistory({ artifacts, activeId, onSelect, compact = false }: { a
return <section className={`workflow-history ${compact ? "is-compact" : ""}`}><div className="workflow-history-title">历史版本</div>{artifacts.map((artifact) => <button key={artifact.id} type="button" className={artifact.id === activeId ? "is-active" : ""} onClick={() => onSelect(artifact)}>{artifact.resourceId ? artifact.type === "video" ? <video src={resourceFileUrl(artifact.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(artifact.resourceId)} alt="" loading="lazy" /> : <span className="workflow-history-placeholder"><Layers3 /></span>}<span className="min-w-0 flex-1"><strong>v{artifact.version}{artifact.selected ? " · 当前" : ""}</strong><small>{new Date(artifact.createdAt).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</small></span><ArtifactStatus artifact={artifact} compact /></button>)}</section>;
}
function ShotTimeline({ detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) {
return <section className="workflow-shot-timeline"><header><div><strong>{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}</strong><span>{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}</span></div><div className="flex items-center gap-1 text-[var(--fs-micro)] text-foreground/40"><List className="size-3.5" /> 共 {shots.length} 镜</div></header><div className="workflow-shot-track thin-scrollbar">{shots.map((shot, index) => <TimelineShot key={shot.id} detail={detail} shot={shot} index={index} selected={shot.id === selectedShotId} onSelect={() => onSelectShot(shot.id)} />)}<button type="button" className="workflow-add-shot-card" disabled={addingShot} onClick={onAddShot}><Plus className="size-5" /><span>新增分镜</span></button></div></section>;
function ShotTimeline({ activeStage, detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { activeStage: ShortDramaWorkflowStage; detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) {
const artifactType = artifactTypeForStage(activeStage);
const latestTaskByShotId = useMemo(() => {
const tasks = new Map<string, GenerationTask>();
for (const task of detail.tasks || []) {
const shotId = task.clientContext?.shotId;
if (!shotId || task.clientContext?.artifactType !== artifactType) continue;
const current = tasks.get(shotId);
if (!current || task.updatedAt > current.updatedAt) tasks.set(shotId, task);
}
return tasks;
}, [artifactType, detail.tasks]);
return <section className="workflow-shot-timeline"><header><div><strong>{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}</strong><span>{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}</span></div><div className="flex items-center gap-1 text-[var(--fs-micro)] text-foreground/40"><List className="size-3.5" /> 共 {shots.length} 镜</div></header><div className="workflow-shot-track thin-scrollbar">{shots.map((shot, index) => <TimelineShot key={shot.id} artifactType={artifactType} detail={detail} shot={shot} task={latestTaskByShotId.get(shot.id)} index={index} selected={shot.id === selectedShotId} onSelect={() => onSelectShot(shot.id)} />)}<button type="button" className="workflow-add-shot-card" disabled={addingShot} onClick={onAddShot}><Plus className="size-5" /><span>新增分镜</span></button></div></section>;
}
function TimelineShot({ detail, shot, index, selected, onSelect }: { detail: ProjectDetail; shot: ProjectShot; index: number; selected: boolean; onSelect: () => void }) {
function TimelineShot({ artifactType, detail, shot, task, index, selected, onSelect }: { artifactType: string; detail: ProjectDetail; shot: ProjectShot; task?: GenerationTask; index: number; selected: boolean; onSelect: () => void }) {
const video = currentArtifact(detail, shot.id, "video");
const previz = currentArtifact(detail, shot.id, "action_board");
const preview = video?.resourceId ? video : previz?.resourceId ? previz : undefined;
const stateArtifact = video || previz;
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span><strong>SC.{String(index + 1).padStart(2, "0")}</strong><small>{formatDuration(shot.durationMs)}</small></span><em>{shot.title}</em><ArtifactStatus artifact={stateArtifact} compact /></span></button>;
const storyboard = currentArtifact(detail, shot.id, "storyboard");
const preview = video?.resourceId ? video : previz?.resourceId ? previz : storyboard?.resourceId ? storyboard : undefined;
const stateArtifact = artifactType === "video" ? video : artifactType === "action_board" ? previz : storyboard;
const revision = currentRevision(detail, shot);
const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面";
const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数";
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={task?.status} compact /></span></span></button>;
}
function revisionInput(values: ShotEditorValues): ShotRevisionInput {
@@ -445,14 +547,6 @@ function revisionInput(values: ShotEditorValues): ShotRevisionInput {
};
}
function shotReferenceImages(detail: ProjectDetail, shotId: string): ReferenceImage[] {
const versionIds = new Set((detail.shotReferences || []).filter((item) => item.shotId === shotId).map((item) => item.assetVersionId));
return detail.assets.filter((asset) => asset.primaryVersionId && versionIds.has(asset.primaryVersionId) && asset.mediaType === "image" && asset.storageKey).map((asset) => {
const resourceId = resourceIdFromStorageKey(asset.storageKey);
return { id: asset.id, name: asset.title, type: "image/*", dataUrl: "", ...(resourceId ? { url: resourceFileUrl(resourceId) } : {}), storageKey: asset.storageKey };
});
}
async function downloadArtifact(artifact: ShotArtifact, shotTitle: string, onError: (content: string) => void) {
if (!artifact.resourceId) return;
try {

View File

@@ -4,6 +4,7 @@ import { Check, CircleAlert } from "lucide-react";
import { Link } from "react-router";
import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects";
import type { TaskStatus } from "@/services/api/task-center";
export type ShortDramaWorkflowStage = "story" | "assets" | "storyboard" | "previz" | "video" | "delivery";
@@ -38,11 +39,17 @@ export function MetricCard({ icon, label, value }: { icon: ReactNode; label: str
return <div className="rounded-xl border border-border/70 bg-surface p-5"><div className="text-foreground/35">{icon}</div><div className="mt-4 text-2xl font-semibold">{value}</div><div className="mt-1 text-xs text-foreground/45">{label}</div></div>;
}
export function ArtifactStatus({ artifact, compact = false }: { artifact?: ShotArtifact; compact?: boolean }) {
if (!artifact) return <Tag className={compact ? "!m-0" : ""}>待生成</Tag>;
const color = artifact.status === "ready" ? "green" : artifact.status === "failed" ? "red" : artifact.status === "stale" ? "orange" : "blue";
const label = artifact.status === "ready" ? "已生成" : artifact.status === "failed" ? "失败" : artifact.status === "stale" ? "已过期" : "生成中";
return <Tag className={compact ? "!m-0" : ""} color={color}>{label}</Tag>;
export function ArtifactStatus({ artifact, taskStatus, compact = false }: { artifact?: ShotArtifact; taskStatus?: TaskStatus; compact?: boolean }) {
const className = `artifact-status-tag ${compact ? "!m-0" : ""}`;
if (taskStatus === "queued" || taskStatus === "running" || (taskStatus === "succeeded" && !artifact)) {
return <Tag className={`${className} is-running`} color="processing">生成中</Tag>;
}
if (taskStatus === "failed") return <Tag className={`${className} is-failed`} color="error">生成失败</Tag>;
if (!artifact) return <Tag className={`${className} is-pending`}>待生成</Tag>;
const color = artifact.status === "ready" ? "success" : artifact.status === "failed" ? "error" : artifact.status === "stale" ? "warning" : "processing";
const label = artifact.status === "ready" ? "已生成" : artifact.status === "failed" ? "生成失败" : artifact.status === "stale" ? "已过期" : "生成中";
const tone = artifact.status === "ready" ? "ready" : artifact.status === "failed" ? "failed" : artifact.status === "stale" ? "stale" : "running";
return <Tag className={`${className} is-${tone}`} color={color}>{label}</Tag>;
}
export function currentRevision(detail: ProjectDetail, shot?: ProjectShot): ShotRevision | undefined {

View File

@@ -0,0 +1,89 @@
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import type { AssetCategory } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
export type ShotAssetReferenceContext = {
mentionReferences: CanvasResourceReference[];
referenceImages: ReferenceImage[];
};
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext {
const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset]));
const seenAssetIds = new Set<string>();
const entries = (detail.shotReferences || []).flatMap((reference) => {
if (reference.shotId !== shotId || reference.status !== "linked") return [];
const asset = assetByVersionId.get(reference.assetVersionId);
if (!asset || seenAssetIds.has(asset.id)) return [];
const image = projectAssetReferenceImage(asset);
if (!image) return [];
seenAssetIds.add(asset.id);
return [{ asset, image }];
});
return {
mentionReferences: entries.map(({ asset, image }) => ({
id: `project-asset:${asset.id}`,
nodeId: "",
assetId: asset.id,
kind: asset.character ? "character" : "image",
label: asset.title,
title: asset.title,
previewUrl: image.url,
storageKey: image.storageKey,
active: true,
category: projectAssetCategory(asset.category),
})),
referenceImages: entries.map(({ image }) => image),
};
}
export function resolveShotAssetMentionPrompt(prompt: string, context: ShotAssetReferenceContext) {
const imageLabelByAssetId = new Map(context.mentionReferences.map((reference, index) => [reference.assetId, `图片${index + 1}`]));
const unresolved = new Set<string>();
const resolved = prompt.replace(/@\[asset:([^\]]+)\]/g, (token, assetId: string) => {
const label = imageLabelByAssetId.get(assetId);
if (!label) {
unresolved.add(token);
return token;
}
return label;
});
if (unresolved.size) throw new Error(`提示词中的 ${Array.from(unresolved).join("、")} 未绑定到当前镜头,请重新选择资产或删除引用`);
return resolved;
}
function projectAssetReferenceImage(asset: ProjectAsset): ReferenceImage | undefined {
const representation = asset.character
? asset.character.representations.find((item) => item.role === "turnaround_sheet")
|| asset.character.representations.find((item) => item.role === "primary")
|| asset.character.representations.find((item) => item.role === "front")
: undefined;
if (representation) {
return {
id: asset.id,
name: asset.title,
type: "image/*",
dataUrl: "",
url: resourceFileUrl(representation.resourceId),
storageKey: resourceStorageKey(representation.resourceId),
};
}
if (asset.mediaType !== "image" || !asset.storageKey) return undefined;
const resourceId = resourceIdFromStorageKey(asset.storageKey);
return {
id: asset.id,
name: asset.title,
type: "image/*",
dataUrl: "",
...(resourceId ? { url: resourceFileUrl(resourceId) } : {}),
storageKey: asset.storageKey,
};
}
function projectAssetCategory(value: string): AssetCategory {
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
}

View File

@@ -1,12 +1,19 @@
.workflow-page-root {
display: flex;
width: 100%;
height: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--workspace-content);
}
.workflow-stage-content {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
@@ -20,16 +27,24 @@
.workflow-production-shell {
display: grid;
width: 100%;
height: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
grid-template-rows: minmax(0, 1fr) clamp(168px, 20vh, 210px);
overflow: hidden;
background: var(--workspace-content);
}
.workflow-production-main {
display: grid;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
grid-template-columns: 220px minmax(500px, 1fr) 370px;
grid-template-columns: 220px minmax(0, 1fr) minmax(300px, 370px);
overflow: hidden;
border-bottom: 1px solid var(--workspace-border);
}
@@ -379,7 +394,7 @@
min-height: 48px;
flex-wrap: wrap;
align-items: center;
gap: 6px;
gap: 8px;
margin-top: 5px;
border: 1px dashed var(--workspace-border-strong);
border-radius: var(--r-md);
@@ -392,8 +407,10 @@
.workflow-bound-assets-content:hover { border-color: var(--workspace-border-strong); }
.workflow-bound-asset-chip {
display: inline-flex;
min-width: 0;
display: flex;
min-width: 180px;
max-width: 280px;
flex: 1 1 220px;
align-items: center;
overflow: hidden;
border: 1px solid var(--workspace-border);
@@ -403,23 +420,111 @@
font-size: var(--fs-tiny);
}
.workflow-bound-asset-chip em {
align-self: stretch;
padding: 4px 6px;
background: var(--workspace-accent-soft);
.workflow-bound-asset-preview {
display: grid;
width: 40px;
height: 40px;
flex: 0 0 40px;
place-items: center;
overflow: hidden;
border-right: 1px solid var(--workspace-border);
background: color-mix(in srgb, var(--workspace-surface-strong) 92%, var(--workspace-accent) 8%);
color: color-mix(in srgb, var(--foreground) 42%, transparent);
}
.workflow-bound-asset-preview .ant-image,
.workflow-bound-asset-preview .ant-image-img { display: block; width: 40px !important; height: 40px !important; }
.workflow-bound-asset-preview .ant-image-img { object-fit: cover; }
.workflow-bound-asset-preview .ant-image-mask { font-size: var(--fs-micro); }
.workflow-bound-asset-preview > svg { width: 16px; height: 16px; }
.workflow-bound-asset-copy {
display: grid;
min-width: 0;
flex: 1 1 auto;
gap: 2px;
padding: 5px 8px;
}
.workflow-bound-asset-copy em {
color: color-mix(in srgb, var(--foreground) 54%, transparent);
font-size: var(--fs-micro);
font-style: normal;
font-weight: 650;
}
.workflow-bound-asset-chip > span {
.workflow-bound-asset-copy strong {
overflow: hidden;
padding: 4px 7px;
color: color-mix(in srgb, var(--foreground) 76%, transparent);
font-size: var(--fs-tiny);
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-bound-asset-chip > button {
display: grid;
width: 25px;
height: 40px;
flex: 0 0 25px;
place-items: center;
border-left: 1px solid var(--workspace-border);
color: color-mix(in srgb, var(--foreground) 42%, transparent);
}
.workflow-bound-asset-chip > button:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--foreground);
}
.workflow-bound-asset-chip > button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.workflow-bound-asset-chip > button > svg { width: 13px; height: 13px; }
.workflow-shot-mention-container {
min-height: 82px;
overflow: visible;
border: 1px solid var(--workspace-border-strong);
border-radius: var(--r-md);
background: var(--workspace-surface);
transition: border-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.workflow-shot-mention-container.is-scene { min-height: 116px; }
.workflow-shot-mention-container:focus-within {
border-color: var(--workspace-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 14%, transparent);
}
.workflow-shot-mention-editor {
display: block;
width: 100%;
min-height: 80px;
max-height: 144px;
resize: vertical;
overflow-y: auto;
border: 0;
background: transparent;
padding: 8px 11px;
color: var(--foreground);
font-size: var(--fs-body);
line-height: 1.55;
outline: 0;
}
.workflow-shot-mention-editor.is-scene {
min-height: 114px;
max-height: 220px;
}
.workflow-shot-mention-editor:empty::before {
color: color-mix(in srgb, var(--foreground) 32%, transparent);
}
.workflow-editor-actions {
display: flex;
min-height: 58px;
@@ -522,8 +627,12 @@
.workflow-shot-timeline {
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--workspace-surface);
}
@@ -541,17 +650,22 @@
.workflow-shot-track {
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
flex: 1 1 auto;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding: 0 14px 12px;
}
.workflow-timeline-shot,
.workflow-add-shot-card {
width: 148px;
min-width: 148px;
width: 184px;
min-width: 184px;
overflow: hidden;
border: 1px solid var(--workspace-border);
border-radius: var(--r-md);
@@ -560,6 +674,8 @@
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.workflow-timeline-shot { position: relative; display: flex; flex-direction: column; }
.workflow-timeline-shot:hover,
.workflow-timeline-shot:focus-visible { border-color: var(--workspace-border-strong); background: var(--surface-hover); }
.workflow-timeline-shot:focus-visible,
@@ -568,7 +684,8 @@
.workflow-timeline-media {
display: grid;
height: 62px;
height: 72px;
flex: 0 0 72px;
place-items: center;
overflow: hidden;
background: color-mix(in srgb, var(--foreground) 5%, var(--workspace-surface));
@@ -577,11 +694,20 @@
.workflow-timeline-media img,
.workflow-timeline-media video { width: 100%; height: 100%; object-fit: cover; }
.workflow-timeline-media > svg { width: 20px; height: 20px; }
.workflow-timeline-copy { display: block; padding: 7px 9px 8px; }
.workflow-timeline-copy > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.workflow-timeline-copy strong { font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums; }
.workflow-timeline-copy small { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: 10px; }
.workflow-timeline-copy em { display: block; overflow: hidden; margin: 3px 0 6px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-micro); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.workflow-timeline-copy { display: flex; min-height: 0; flex: 1 1 auto; flex-direction: column; padding: 8px 10px 34px; }
.workflow-timeline-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.workflow-timeline-heading strong { font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums; }
.workflow-timeline-heading b { color: var(--foreground); font-size: var(--fs-caption); font-weight: 750; font-variant-numeric: tabular-nums; }
.workflow-timeline-title { display: block; min-height: 16px; overflow: hidden; margin-top: 5px; color: color-mix(in srgb, var(--foreground) 72%, transparent); font-size: var(--fs-label); font-style: normal; font-weight: 550; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.workflow-timeline-meta { display: block; overflow: hidden; margin-top: 4px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
.workflow-timeline-status { position: absolute; right: 10px; bottom: 8px; left: 10px; display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 6px; }
.workflow-timeline-status > span:first-child { min-width: 0; overflow: hidden; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
.artifact-status-tag.ant-tag { display: inline-flex; min-height: 20px; flex: 0 0 auto; align-items: center; border-radius: var(--r-full); padding-inline: 7px; font-size: var(--fs-micro); font-weight: 600; line-height: 18px; white-space: nowrap; }
.artifact-status-tag.is-pending.ant-tag { border-color: color-mix(in srgb, var(--foreground) 18%, transparent) !important; background: color-mix(in srgb, var(--foreground) 7%, transparent) !important; color: color-mix(in srgb, var(--foreground) 62%, transparent) !important; }
.artifact-status-tag.is-running.ant-tag { border-color: color-mix(in srgb, var(--palette-status-loading) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-loading) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-loading) 82%, var(--foreground)) !important; }
.artifact-status-tag.is-ready.ant-tag { border-color: color-mix(in srgb, var(--palette-status-success) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-success) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-success) 82%, var(--foreground)) !important; }
.artifact-status-tag.is-failed.ant-tag { border-color: color-mix(in srgb, var(--palette-status-error) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-error) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-error) 82%, var(--foreground)) !important; }
.artifact-status-tag.is-stale.ant-tag { border-color: color-mix(in srgb, var(--palette-status-warning) 42%, transparent) !important; background: color-mix(in srgb, var(--palette-status-warning) 12%, transparent) !important; color: color-mix(in srgb, var(--palette-status-warning) 80%, var(--foreground)) !important; }
.workflow-add-shot-card {
display: grid;
@@ -624,12 +750,12 @@
}
@media (max-width: 1280px) {
.workflow-production-main { grid-template-columns: 204px minmax(420px, 1fr) 320px; }
.workflow-production-main { grid-template-columns: 204px minmax(0, 1fr) minmax(280px, 320px); }
.workflow-disclosure-summary > span.is-model { display: none; }
}
@media (max-width: 1050px) {
.workflow-production-main { grid-template-columns: minmax(420px, 1fr) 300px; }
.workflow-production-main { grid-template-columns: minmax(0, 1fr) 300px; }
.workflow-library-panel { display: none; }
}
@@ -644,6 +770,12 @@
.workflow-shot-form-scroll { padding-inline: 14px; }
.workflow-timeline-shot,
.workflow-add-shot-card { width: 138px; min-width: 138px; }
.workflow-timeline-media { height: 52px; flex-basis: 52px; }
.workflow-timeline-title,
.workflow-timeline-meta,
.workflow-timeline-status > span:first-child { display: none; }
.workflow-timeline-copy { padding: 6px 8px 30px; }
.workflow-timeline-status { right: 8px; bottom: 6px; left: 8px; justify-content: flex-end; }
.workflow-overview-scroll { padding: 20px 16px 48px; }
}

View File

@@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from "react-router";
import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { CanvasStylePickerModal, resolveCanvasStylePreset, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { resourceFileUrl } from "@/services/api/resources";
import { ModelPicker } from "@/components/model-picker";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
@@ -373,7 +374,7 @@ function ProjectRow({ row, onDelete }: { row: ProjectSummary; onDelete: () => vo
const stage = projectSummaryStage(row);
const projectStyle = resolveProjectCanvasStyle(row.project.stylePresetId, row.project.styleProfileJson);
const styleTitle = projectStyle?.title || parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
const coverUrl = projectStyle?.imageUrl;
const coverUrl = row.project.coverResourceId ? resourceFileUrl(row.project.coverResourceId) : projectStyle?.imageUrl;
return (
<Link to={`/projects/${row.project.id}/overview`} className="library-card project-library-card group">
<span className="project-library-cover">

View File

@@ -3,13 +3,14 @@ import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, Mo
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ListToolbar, PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { ListToolbar, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLabel } from "@/pages/skills/skill-catalog";
import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer";
import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer";
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
import { SkillInstallModal } from "@/pages/skills/skill-install-modal";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
const scopeOptions = [
{ label: "技能广场", value: "public", icon: Sparkles },
@@ -64,6 +65,7 @@ export default function SkillsPage() {
const [detailLoading, setDetailLoading] = useState(false);
const [mutatingID, setMutatingID] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
@@ -163,6 +165,20 @@ export default function SkillsPage() {
}
};
const synchronizeSkill = async (skill: Skill) => {
setMutatingID(skill.skill_id);
try {
const result = await syncSkill(skill.skill_id);
patchSkill(result.skill);
message.success(result.skill.version_id === skill.version_id ? "已是最新版本" : "已同步最新版本");
reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "GitHub 技能同步失败");
} finally {
setMutatingID("");
}
};
const confirmDelete = (skill: Skill) => {
modal.confirm({
title: `删除“${skill.skill_name}”?`,
@@ -239,7 +255,7 @@ export default function SkillsPage() {
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => setInstallOpen(true)}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">安装技能</span><span className="library-create-meta">上传 MD / ZIP 或连接 GitHub</span></button> : null}
{group.skills.map((skill, index) => <SkillCard key={skill.skill_id} skill={skill} categories={categories} loading={mutatingID === skill.skill_id} style={{ animationDelay: `${Math.min(index, 10) * 45}ms` }} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
@@ -256,7 +272,7 @@ export default function SkillsPage() {
action={filtersActive
? <Button onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>创建技能</Button>
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
)}
@@ -264,7 +280,8 @@ export default function SkillsPage() {
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</WorkspacePage>
<SkillDetailDrawer skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} />
<SkillDetailModal skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} />
<SkillInstallModal open={installOpen} onClose={() => setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} />
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
</>
);

View File

@@ -1,57 +1,276 @@
import { Button, Drawer, Skeleton, Tooltip } from "antd";
import { Check, ExternalLink, Heart, Pencil, Plus, Users } from "lucide-react";
import { Button, Input, Modal, Segmented, Skeleton, Tooltip, Tree } from "antd";
import type { DataNode } from "antd/es/tree";
import { Check, ChevronRight, Code2, ExternalLink, File, FileArchive, FileCode2, FileImage, FileText, Folder, FolderOpen, Heart, Pencil, Plus, RefreshCw, Users } from "lucide-react";
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { formatSkillCount, formatSkillDate, skillCategoryLabel } from "@/pages/skills/skill-catalog";
import type { Skill, SkillCategory } from "@/services/api/skills";
import { getSkillFile, listSkillFiles, skillFileRawURL, type Skill, type SkillCategory, type SkillPackageFile, type SkillPackageFileContent } from "@/services/api/skills";
type PreviewMode = "preview" | "source";
export function SkillDetailModal({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit, onSync }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void; onSync: (skill: Skill) => void }) {
const [files, setFiles] = useState<SkillPackageFile[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState("");
const [activePath, setActivePath] = useState("SKILL.md");
const [content, setContent] = useState<SkillPackageFileContent | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState("");
const [previewMode, setPreviewMode] = useState<PreviewMode>("preview");
const [pathFilter, setPathFilter] = useState("");
useEffect(() => {
if (!skill) {
setFiles([]);
setContent(null);
return;
}
let cancelled = false;
setFilesLoading(true);
setFilesError("");
setPathFilter("");
listSkillFiles(skill.skill_id)
.then((result) => {
if (cancelled) return;
setFiles(result.files);
const first = result.files.find((file) => file.path === "SKILL.md")?.path || result.files[0]?.path || "";
setActivePath(first);
})
.catch((error) => {
if (cancelled) return;
setFiles([]);
setFilesError(error instanceof Error ? error.message : "技能文件加载失败");
})
.finally(() => {
if (!cancelled) setFilesLoading(false);
});
return () => { cancelled = true; };
}, [skill?.skill_id, skill?.version_id]);
useEffect(() => {
if (!skill || !activePath) {
setContent(null);
return;
}
let cancelled = false;
setContentLoading(true);
setContentError("");
getSkillFile(skill.skill_id, activePath)
.then((result) => { if (!cancelled) setContent(result.file); })
.catch((error) => {
if (cancelled) return;
setContent(null);
setContentError(error instanceof Error ? error.message : "文件读取失败");
})
.finally(() => { if (!cancelled) setContentLoading(false); });
return () => { cancelled = true; };
}, [activePath, skill?.skill_id, skill?.version_id]);
useEffect(() => {
setPreviewMode("preview");
}, [activePath]);
const visibleFiles = useMemo(() => {
const needle = pathFilter.trim().toLowerCase();
return needle ? files.filter((file) => file.path.toLowerCase().includes(needle)) : files;
}, [files, pathFilter]);
const treeData = useMemo(() => buildSkillTree(visibleFiles), [visibleFiles]);
const filePaths = useMemo(() => new Set(files.map((file) => file.path)), [files]);
const selectedFile = files.find((file) => file.path === activePath);
const canPreviewMarkdown = selectedFile?.kind === "markdown" && !content?.binary;
export function SkillDetailDrawer({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void }) {
return (
<Drawer className="library-drawer" open={Boolean(skill)} size={760} destroyOnHidden title={skill?.skill_name || "技能详情"} onClose={onClose} extra={skill?.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : undefined}>
<Modal
className="skill-package-modal"
open={Boolean(skill)}
width="82vw"
footer={null}
destroyOnHidden
onCancel={onClose}
styles={{ container: { height: "82vh", padding: 0, overflow: "hidden" }, body: { height: "100%", padding: 0 } }}
>
{skill ? (
<div className="space-y-6">
<header>
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/50">
<span>{skillCategoryLabel(skill.tag, categories)}</span><span aria-hidden="true">/</span><span>{skill.is_private ? "仅自己可见" : "公开技能"}</span><span aria-hidden="true">/</span><span>更新于 {formatSkillDate(skill.update_time)}</span>
<div className="skill-package-shell">
<header className="skill-package-header">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[var(--fs-label)] text-foreground/45">
<span>{skillCategoryLabel(skill.tag, categories)}</span><span aria-hidden="true">/</span><span>{sourceLabel(skill.source_type)}</span><span aria-hidden="true">/</span><span>v{skill.version || "1"}</span><span aria-hidden="true">/</span><span>更新于 {formatSkillDate(skill.update_time)}</span>
</div>
<h1 className="mt-1 truncate text-[var(--fs-heading-lg)] font-semibold text-foreground">{skill.skill_name}</h1>
<p className="mt-1 line-clamp-2 max-w-4xl text-sm leading-5 text-foreground/58">{skill.description}</p>
</div>
<p className="mt-3 text-sm leading-6 text-foreground/72">{skill.description}</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button type={skill.is_added ? "default" : "primary"} loading={mutating} disabled={skill.is_owner} icon={skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />} onClick={() => onAdd(skill)}>{skill.is_owner ? "我的技能" : skill.is_added ? "已加入" : "加入技能"}</Button>
<div className="skill-package-actions">
{skill.source_type === "github" && skill.is_owner ? <Tooltip title={skill.sync_error || "从 GitHub 检查并同步最新提交"}><Button loading={mutating} icon={<RefreshCw className="size-4" />} onClick={() => onSync(skill)}>同步</Button></Tooltip> : null}
{skill.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : null}
<Button loading={mutating} icon={<Heart className={`size-4 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />} onClick={() => onLike(skill)}>{skill.is_like ? "已收藏" : "收藏"}</Button>
<span className="ml-auto inline-flex items-center gap-1.5 text-xs text-foreground/45"><Users className="size-3.5" />{formatSkillCount(skill.added_count)} 人加入</span>
<Button type={skill.is_added ? "default" : "primary"} loading={mutating} disabled={skill.is_owner} icon={skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />} onClick={() => onAdd(skill)}>{skill.is_owner ? "我的技能" : skill.is_added ? "已加入" : "加入技能"}</Button>
</div>
</header>
{skill.showcase_media.length ? <SkillMediaGallery skill={skill} /> : null}
<div className="skill-package-workspace">
<aside className="skill-package-sidebar" aria-label="技能文件">
<div className="skill-package-sidebar-summary">
<span><FileArchive className="size-3.5" />{skill.file_count || files.length} 个文件</span>
<span>{formatBytes(skill.total_bytes)}</span>
</div>
<Input allowClear size="small" value={pathFilter} onChange={(event) => setPathFilter(event.target.value)} placeholder="筛选文件…" prefix={<File className="size-3.5 text-foreground/30" />} />
<div className="skill-package-tree thin-scrollbar">
{filesLoading || loading ? <Skeleton active title={false} paragraph={{ rows: 10 }} /> : filesError ? <div className="skill-package-empty">{filesError}</div> : treeData.length ? (
<Tree
blockNode
showIcon
showLine={false}
defaultExpandAll
expandAction="click"
selectedKeys={activePath ? [activePath] : []}
treeData={treeData}
switcherIcon={({ expanded, isLeaf }) => isLeaf ? null : <ChevronRight aria-hidden="true" className={`skill-package-tree-chevron size-3.5 ${expanded ? "is-expanded" : ""}`} />}
onSelect={(keys, info) => { if (!info.node.children?.length && keys[0]) setActivePath(String(keys[0])); }}
/>
) : <div className="skill-package-empty">没有匹配文件</div>}
</div>
<div className="skill-package-sidebar-footer">
<span className="inline-flex items-center gap-1"><Users className="size-3.5" />{formatSkillCount(skill.added_count)} 人加入</span>
<span>{skill.is_private ? "仅自己可见" : "公开"}</span>
</div>
</aside>
<section aria-labelledby="skill-instruction-title">
<div className="mb-2 flex items-center justify-between gap-3">
<h2 id="skill-instruction-title" className="text-sm font-semibold">技能指令</h2>
{skill.markdown_url ? <Tooltip title="打开 Markdown 原文"><a className="inline-flex size-8 items-center justify-center rounded-md text-foreground/55 hover:bg-surface-hover hover:text-foreground" href={skill.markdown_url} target="_blank" rel="noreferrer" aria-label="打开 Markdown 原文"><ExternalLink className="size-4" /></a></Tooltip> : null}
</div>
{loading ? <Skeleton active paragraph={{ rows: 14 }} /> : <pre className="thin-scrollbar max-h-[52vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-foreground/[.025] p-4 font-mono text-xs leading-6 text-foreground/78">{skill.instruction || "暂无技能指令"}</pre>}
</section>
<section className="border-t border-border/70 pt-4 text-xs leading-5 text-foreground/48">
<div>作者:{skill.effective_user.name || "未知用户"}</div>
{skill.extra_info ? <div className="mt-2 whitespace-pre-wrap">{skill.extra_info}</div> : null}
</section>
<main className="skill-package-preview">
<div className="skill-package-preview-toolbar">
<div className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/58">{activePath || "请选择文件"}</div>
{canPreviewMarkdown ? <Segmented size="small" value={previewMode} onChange={(value) => setPreviewMode(value as PreviewMode)} options={[{ value: "preview", label: <span className="inline-flex items-center gap-1"><FileText className="size-3.5" />预览</span> }, { value: "source", label: <span className="inline-flex items-center gap-1"><Code2 className="size-3.5" />源码</span> }]} /> : null}
{activePath ? <Tooltip title="打开原始文件"><a className="skill-package-raw-link" href={skillFileRawURL(skill.skill_id, activePath)} target="_blank" rel="noreferrer" aria-label="打开原始文件"><ExternalLink className="size-4" /></a></Tooltip> : null}
</div>
<div className="skill-package-preview-body thin-scrollbar">
{contentLoading ? <Skeleton active paragraph={{ rows: 18 }} /> : contentError ? <div className="skill-package-empty">{contentError}</div> : content && selectedFile ? (
<SkillFilePreview skill={skill} file={selectedFile} content={content} mode={previewMode} filePaths={filePaths} onNavigate={setActivePath} />
) : <div className="skill-package-empty">从左侧选择一个文件</div>}
</div>
</main>
</div>
</div>
) : null}
</Drawer>
</Modal>
);
}
function SkillMediaGallery({ skill }: { skill: Skill }) {
function SkillFilePreview({ skill, file, content, mode, filePaths, onNavigate }: { skill: Skill; file: SkillPackageFile; content: SkillPackageFileContent; mode: PreviewMode; filePaths: Set<string>; onNavigate: (path: string) => void }) {
const rawURL = skillFileRawURL(skill.skill_id, file.path);
if (content.binary) {
if (file.kind === "image") return <div className="skill-package-media-stage"><img src={rawURL} alt={file.path} /></div>;
if (file.kind === "video") return <div className="skill-package-media-stage"><video src={rawURL} controls playsInline preload="metadata" /></div>;
if (file.kind === "audio") return <div className="skill-package-media-stage"><audio src={rawURL} controls preload="metadata" /></div>;
return <div className="skill-package-empty"><FileArchive className="mb-3 size-9" /><div>该文件不支持在线预览</div><a className="mt-3 inline-flex items-center gap-1 text-foreground underline" href={rawURL} target="_blank" rel="noreferrer">打开原始文件<ExternalLink className="size-3.5" /></a></div>;
}
if (file.kind === "markdown" && mode === "preview") {
return <SkillMarkdown source={content.content} currentPath={file.path} filePaths={filePaths} onNavigate={onNavigate} />;
}
return <pre className="skill-package-source"><code>{content.content}</code></pre>;
}
function SkillMarkdown({ source, currentPath, filePaths, onNavigate }: { source: string; currentPath: string; filePaths: Set<string>; onNavigate: (path: string) => void }) {
const navigate = (event: MouseEvent<HTMLAnchorElement>, href?: string) => {
const target = resolveSkillPath(currentPath, href || "");
if (!target || !filePaths.has(target)) return;
event.preventDefault();
onNavigate(target);
};
return (
<section aria-labelledby="skill-showcase-title">
<h2 id="skill-showcase-title" className="mb-2 text-sm font-semibold">展示案例</h2>
<div className="thin-scrollbar flex snap-x gap-3 overflow-x-auto pb-2">
{skill.showcase_media.map((media, index) => (
<div key={`${media.showcase_url}-${index}`} className="aspect-video w-[min(78vw,420px)] shrink-0 snap-start overflow-hidden rounded-md border border-border/70 bg-black/90">
{media.type === "video" ? <video className="h-full w-full object-contain" controls playsInline preload="metadata" src={media.showcase_url} /> : <img className="h-full w-full object-contain" src={media.showcase_url} alt={`${skill.skill_name} 展示案例 ${index + 1}`} width={840} height={472} loading="lazy" />}
</div>
))}
</div>
</section>
<article className="skill-package-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, href }) => <a href={href} target={isExternalURL(href) ? "_blank" : undefined} rel={isExternalURL(href) ? "noreferrer" : undefined} onClick={(event) => navigate(event, href)}>{children}</a>,
table: ({ children }) => <div className="skill-package-table-wrap"><table>{children}</table></div>,
img: ({ src, alt }) => {
if (isExternalURL(src)) return <img src={src} alt={alt || ""} loading="lazy" />;
const target = resolveSkillPath(currentPath, src || "");
if (target && filePaths.has(target)) return <button type="button" className="skill-package-inline-file" onClick={() => onNavigate(target)}><FileImage className="size-4" />{alt || target}</button>;
return <span className="skill-package-inline-missing">{alt || src || "图片"}</span>;
},
}}
>{source}</ReactMarkdown>
</article>
);
}
function buildSkillTree(files: SkillPackageFile[]): DataNode[] {
type MutableNode = DataNode & { children?: MutableNode[]; file?: SkillPackageFile };
const roots: MutableNode[] = [];
const folders = new Map<string, MutableNode>();
for (const file of files) {
const parts = file.path.split("/");
let children = roots;
let prefix = "";
parts.forEach((part, index) => {
const isLeaf = index === parts.length - 1;
prefix = prefix ? `${prefix}/${part}` : part;
if (isLeaf) {
children.push({ key: file.path, title: treeTitle(part, file), icon: fileIcon(file), isLeaf: true, file });
return;
}
let folder = folders.get(prefix);
if (!folder) {
folder = {
key: `folder:${prefix}`,
title: <span className="skill-package-folder-title">{part}</span>,
icon: ({ expanded }) => expanded ? <FolderOpen aria-hidden="true" className="size-4" /> : <Folder aria-hidden="true" className="size-4" />,
children: [],
};
folders.set(prefix, folder);
children.push(folder);
}
children = folder.children || [];
});
}
return roots;
}
function treeTitle(name: string, file: SkillPackageFile): ReactNode {
return <span className="skill-package-tree-title"><span>{name}</span><span>{formatBytes(file.size)}</span></span>;
}
function fileIcon(file: SkillPackageFile) {
if (file.kind === "markdown" || file.kind === "text") return <FileText aria-hidden="true" className="size-3.5" />;
if (file.kind === "code") return <FileCode2 aria-hidden="true" className="size-3.5" />;
if (file.kind === "image") return <FileImage aria-hidden="true" className="size-3.5" />;
return <File aria-hidden="true" className="size-3.5" />;
}
function resolveSkillPath(currentPath: string, href: string) {
const cleanHref = href.split("#", 1)[0]?.split("?", 1)[0] || "";
if (!cleanHref || isExternalURL(cleanHref) || cleanHref.startsWith("/")) return "";
const base = currentPath.split("/").slice(0, -1);
let decoded = cleanHref;
try {
decoded = decodeURIComponent(cleanHref);
} catch {
return "";
}
for (const segment of decoded.replaceAll("\\", "/").split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") base.pop();
else base.push(segment);
}
return base.join("/");
}
function isExternalURL(value?: string) {
return Boolean(value && /^(https?:|mailto:|data:)/i.test(value));
}
function sourceLabel(source: string) {
if (source === "github") return "GitHub";
if (source === "zip") return "ZIP 技能包";
if (source === "builtin") return "内置技能";
return "Markdown";
}
function formatBytes(value: number) {
if (!value) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -18,6 +18,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
const [draftIdea, setDraftIdea] = useState("");
const [drafting, setDrafting] = useState(false);
const effectiveConfig = useEffectiveConfig();
const isPackageSkill = Boolean(skill && skill.source_type !== "markdown" && skill.source_type !== "builtin" && skill.source_type !== "");
useEffect(() => {
if (!open) return;
@@ -27,7 +28,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
instruction: skill?.instruction || "",
tag: skill?.tag || "creative",
is_public: skill ? !skill.is_private : true,
markdown_url: skill?.markdown_url || "",
markdown_url: skill?.markdown_url || skill?.source_url || "",
showcase_media: skill?.showcase_media || [],
extra_info: skill?.extra_info || "",
});
@@ -48,7 +49,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
const input: SkillMutationInput = {
skill_name: values.skill_name,
description: values.description,
instruction: values.instruction,
instruction: values.instruction || "",
tag: values.tag,
is_private: !values.is_public,
markdown_url: values.markdown_url || "",
@@ -97,7 +98,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
return (
<Drawer className="library-drawer" open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
<div className="mb-4 rounded-xl border bg-foreground/[.02] p-3">
{!isPackageSkill ? <div className="mb-4 rounded-xl border bg-foreground/[.02] p-3">
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<Wand2 className="size-4" />
AI 起草
@@ -116,7 +117,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
<span className="text-xs text-foreground/45">将使用你的文本模型生成一次草稿</span>
<Button type="primary" loading={drafting} disabled={!draftIdea.trim()} icon={<Wand2 className="size-4" />} onClick={() => void draftFromIdea()}>生成草稿</Button>
</div>
</div>
</div> : <div className="mb-4 rounded-xl border bg-foreground/[.02] p-3 text-sm leading-6 text-foreground/58">这是多文件技能包。这里仅编辑名称、简介、分类和展示信息;技能正文请更新 ZIP,或在 GitHub 仓库修改后执行同步。</div>}
<Form form={form} layout="vertical" requiredMark="optional" onFinish={submit} onValuesChange={() => setDirty(true)}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item name="skill_name" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>
@@ -131,12 +132,12 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={500} showCount placeholder="说明适用场景、输入条件和最终产出" />
</Form.Item>
<Form.Item name="instruction" label="技能指令" rules={[{ required: true, message: "请填写技能指令" }, { max: 100000, message: "最多 100000 个字符" }]} extra="画布引用该技能时会把这段指令发送给模型。">
{!isPackageSkill ? <Form.Item name="instruction" label="技能指令" rules={[{ required: true, message: "请填写技能指令" }, { max: 100000, message: "最多 100000 个字符" }]} extra="单文件技能会作为 SKILL.md 安装,Agent 按任务需要读取。">
<Input.TextArea className="font-mono text-xs leading-5" autoSize={{ minRows: 14, maxRows: 28 }} maxLength={100000} showCount placeholder="使用 Markdown 编写角色、约束、流程、检查清单和输出格式" />
</Form.Item>
</Form.Item> : null}
<div className="grid gap-x-4 sm:grid-cols-[minmax(0,1fr)_180px]">
<Form.Item name="markdown_url" label="Markdown 地址" rules={[{ type: "url", message: "请输入有效的 HTTP(S) 链接" }]}>
<Form.Item name="markdown_url" label={isPackageSkill ? "来源地址" : "Markdown 地址"} rules={[{ type: "url", message: "请输入有效的 HTTP(S) 链接" }]}>
<Input type="url" inputMode="url" spellCheck={false} placeholder="https://example.com/SKILL.md" />
</Form.Item>
<Form.Item name="is_public" label="公开状态" valuePropName="checked" extra="公开后其他用户可以加入使用。">

View File

@@ -0,0 +1,145 @@
import { App, Button, Form, Input, Modal, Segmented, Select, Switch, Upload, type UploadFile } from "antd";
import { FileArchive, FileText, GitBranch, UploadCloud } from "lucide-react";
import { useEffect, useState } from "react";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { installGitHubSkill, installSkillUpload, type Skill } from "@/services/api/skills";
type InstallMode = "markdown" | "zip" | "github";
type InstallFormValues = {
name?: string;
description?: string;
tag: string;
is_public: boolean;
url?: string;
ref?: string;
subdir?: string;
auto_update: boolean;
};
const modeOptions = [
{ value: "markdown", label: <span className="inline-flex items-center gap-1.5"><FileText className="size-3.5" />Markdown</span> },
{ value: "zip", label: <span className="inline-flex items-center gap-1.5"><FileArchive className="size-3.5" />ZIP 技能包</span> },
{ value: "github", label: <span className="inline-flex items-center gap-1.5"><GitBranch className="size-3.5" />GitHub</span> },
];
export function SkillInstallModal({ open, onClose, onInstalled, onManualCreate }: { open: boolean; onClose: () => void; onInstalled: (skill: Skill) => void; onManualCreate: () => void }) {
const { message } = App.useApp();
const [form] = Form.useForm<InstallFormValues>();
const [mode, setMode] = useState<InstallMode>("markdown");
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (!open) return;
setMode("markdown");
setFileList([]);
form.setFieldsValue({ tag: "creative", is_public: true, auto_update: true, name: "", description: "", url: "", ref: "", subdir: "" });
}, [form, open]);
const install = async () => {
const values = await form.validateFields();
const file = fileList[0]?.originFileObj;
if (mode !== "github" && !file) {
message.warning(`请选择一个 ${mode === "zip" ? "ZIP 技能包" : "Markdown 文件"}`);
return;
}
setInstalling(true);
try {
const result = mode === "github"
? await installGitHubSkill({
url: values.url || "",
ref: values.ref || undefined,
subdir: values.subdir || undefined,
tag: values.tag,
is_private: !values.is_public,
auto_update: values.auto_update,
})
: await installSkillUpload({
file: file as File,
source_type: mode,
name: values.name || undefined,
description: values.description || undefined,
tag: values.tag,
is_private: !values.is_public,
});
message.success("技能已安装");
onInstalled(result.skill);
} catch (error) {
message.error(error instanceof Error ? error.message : "技能安装失败");
} finally {
setInstalling(false);
}
};
return (
<Modal
className="skill-install-modal"
open={open}
width={680}
destroyOnHidden
maskClosable={!installing}
title="安装技能"
onCancel={onClose}
footer={(
<div className="flex items-center justify-between gap-3">
<Button type="text" onClick={onManualCreate}>从空白创建单文件技能</Button>
<div className="flex gap-2"><Button onClick={onClose}>取消</Button><Button type="primary" loading={installing} onClick={() => void install()}>安装技能</Button></div>
</div>
)}
>
<p className="mb-4 text-sm leading-6 text-foreground/55">支持标准 <code>SKILL.md</code>、包含多层目录的 ZIP 技能包,或公开 GitHub 仓库。名称和简介会优先从技能入口自动读取。</p>
<Segmented className="skill-install-mode" block options={modeOptions} value={mode} onChange={(value) => { setMode(value as InstallMode); setFileList([]); }} />
<Form form={form} layout="vertical" requiredMark="optional" className="skill-install-form">
{mode === "github" ? (
<>
<Form.Item name="url" label="GitHub 地址" rules={[{ required: true, message: "请填写 GitHub 仓库地址" }, { type: "url", message: "请输入有效链接" }]}>
<Input type="url" inputMode="url" spellCheck={false} prefix={<GitBranch className="size-4 text-foreground/35" />} placeholder="https://github.com/owner/repository" />
</Form.Item>
<div className="grid gap-x-3 sm:grid-cols-2">
<Form.Item name="ref" label="分支或标签" extra="留空时使用默认分支"><Input spellCheck={false} placeholder="main" /></Form.Item>
<Form.Item name="subdir" label="技能子目录" extra="仓库仅含一个技能时可留空"><Input spellCheck={false} placeholder="skills/ai-director" /></Form.Item>
</div>
</>
) : (
<>
<Upload.Dragger
accept={mode === "zip" ? ".zip,application/zip" : ".md,.markdown,text/markdown"}
maxCount={1}
fileList={fileList}
beforeUpload={(file) => {
if (file.size > 20 * 1024 * 1024) {
message.error("技能文件不能超过 20MB");
return Upload.LIST_IGNORE;
}
return false;
}}
onChange={({ fileList: next }) => setFileList(next.slice(-1))}
onRemove={() => { setFileList([]); return true; }}
>
<UploadCloud className="mx-auto mb-3 size-8 text-foreground/38" />
<div className="text-sm font-medium">拖入或选择 {mode === "zip" ? "ZIP 技能包" : "Markdown 文件"}</div>
<div className="mt-1 text-xs text-foreground/45">{mode === "zip" ? "根目录或唯一子目录中必须包含 SKILL.md" : "普通 .md 会作为技能入口 SKILL.md 安装"}</div>
</Upload.Dragger>
<div className="mt-4 grid gap-x-3 sm:grid-cols-2">
<Form.Item name="name" label="覆盖名称" extra="可选,留空时自动读取"><Input maxLength={80} autoComplete="off" /></Form.Item>
<Form.Item name="description" label="覆盖简介" extra="可选,留空时自动读取"><Input maxLength={500} autoComplete="off" /></Form.Item>
</div>
</>
)}
<div className="grid gap-x-3 sm:grid-cols-2">
<Form.Item name="tag" label="技能分类" rules={[{ required: true, message: "请选择技能分类" }]}>
<Select options={fallbackSkillCategories} />
</Form.Item>
<Form.Item name="is_public" label="公开状态" valuePropName="checked" extra="公开后其他用户可以加入使用。">
<Switch checkedChildren="公开" unCheckedChildren="私有" />
</Form.Item>
</div>
{mode === "github" ? <Form.Item name="auto_update" label="自动同步" valuePropName="checked" extra="后台每 6 小时检查一次提交版本,并记录最近检查与同步时间。"><Switch checkedChildren="开启" unCheckedChildren="关闭" /></Form.Item> : null}
</Form>
</Modal>
);
}