feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -20,6 +20,7 @@ export default function LogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("filter") || "";
const status = normalizeStatus(searchParams.get("status"));
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = normalizePageSize(searchParams.get("pageSize"));
const debouncedKeyword = useDebouncedValue(keyword);
@@ -30,7 +31,7 @@ export default function LogsPage() {
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const requestSequence = useRef(0);
const hasFilters = Boolean(keyword || status !== "all");
const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
const next = new URLSearchParams(searchParams);
@@ -45,7 +46,7 @@ export default function LogsPage() {
useEffect(() => {
const sequence = ++requestSequence.current;
setLoading(true);
void listAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
setLogs(result.logs);
@@ -55,7 +56,7 @@ export default function LogsPage() {
})
.catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败"))
.finally(() => sequence === requestSequence.current && setLoading(false));
}, [debouncedKeyword, status, page, pageSize]);
}, [debouncedKeyword, status, recordType, page, pageSize]);
const columns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
@@ -129,7 +130,7 @@ export default function LogsPage() {
description="模型生成、状态查询与结果下载;仅计费调用扣除积分"
actions={
<AdminExportButton
exportFile={() => exportAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
label="导出当前筛选"
successMessage="已按当前筛选导出请求明细"
@@ -155,6 +156,8 @@ export default function LogsPage() {
</>
}
toolbarFilters={
<>
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
<Select
className="w-32"
value={status}
@@ -165,15 +168,16 @@ export default function LogsPage() {
{ label: "失败", value: "failed" },
]}
/>
</>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })}
batchActions={
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
<AdminExportButton
type="primary"
size="small"
exportFile={() => exportAdminApiLogs({ ids: selectedIds })}
exportFile={() => exportAdminApiLogs({ recordType, ids: selectedIds })}
fileName={() => `请求明细-已选${selectedIds.length}条.csv`}
label="导出已选"
successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`}

View File

@@ -20,7 +20,6 @@ export type CanvasUploadStatus = {
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploadStatus; theme: CanvasTheme }) {
const progress = Math.round((Math.min(status.step, status.total) / Math.max(status.total, 1)) * 100);
const accent = status.error ? theme.accent.danger : status.done ? "#22c55e" : theme.node.activeStroke;
return (
<motion.div
@@ -67,7 +66,7 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
/>
))}
</div>
<span className="sr-only">{progress}%</span>
<span className="sr-only">处理步骤 {status.step}/{status.total}</span>
</motion.div>
);
}

View File

@@ -4,6 +4,7 @@ import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -24,6 +25,7 @@ type CanvasProjectWorldLayersProps = {
connectingParams: ConnectionHandle | null;
mouseWorld: Position;
connectionTargetNodeId: string | null;
connectionApproach: CanvasConnectionApproach;
nodeById: Map<string, CanvasNodeData>;
visibleNodes: CanvasNodeData[];
nodeStackOrder: CanvasNodeStackOrder;
@@ -159,6 +161,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
connectionApproach={props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined}
forceInputVisible={Boolean(props.batchConnectionPreview)}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}

View File

@@ -12,6 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
@@ -983,6 +984,7 @@ function InfiniteCanvasPage() {
closeConnectionCreateMenu,
connectionTargetAnchorRatio,
connectionTargetNodeId,
connectionApproach,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
@@ -1460,6 +1462,7 @@ function InfiniteCanvasPage() {
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
}, [updateNodeFromContent]);
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
upload: replaceCanvasNodeMedia,
download: downloadNodeImage,
duplicate: duplicateNodeFromContent,
deleteNode: deleteNodeFromContent,
@@ -1468,7 +1471,7 @@ function InfiniteCanvasPage() {
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
openArtCritique,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2295,6 +2298,7 @@ function InfiniteCanvasPage() {
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
<CanvasProjectWorldLayers
connectionApproach={connectionApproach}
projectId={projectId}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
@@ -2497,7 +2501,7 @@ function InfiniteCanvasPage() {
/>
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
node={dialogNode}
viewport={viewport}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
@@ -86,6 +87,7 @@ export function useCanvasConnectionController({
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [connectionApproach, setConnectionApproach] = useState<CanvasConnectionApproach>(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
@@ -108,6 +110,7 @@ export function useCanvasConnectionController({
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
batchConnectionPreviewRef.current = next;
setBatchConnectionPreview(next);
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, next && next.status !== "invalid" ? next.targetNodeId : null, next?.mouseWorld || { x: 0, y: 0 }));
}, []);
const clearBatchConnection = useCallback(() => {
@@ -120,6 +123,7 @@ export function useCanvasConnectionController({
connectingParamsRef.current = next;
setConnectingParams(next);
if (!next) {
setConnectionApproach(null);
connectingPointerIdRef.current = null;
connectingPointerStartRef.current = null;
setConnectionTargetNodeId(null);
@@ -596,9 +600,11 @@ export function useCanvasConnectionController({
const current = connectingParamsRef.current;
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
const point = screenToCanvas(event.clientX, event.clientY);
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
setConnectionTargetNodeId(dropTarget.nodeId);
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
setMouseWorld(point);
};
const handlePointerMove = (event: PointerEvent) => {
// Pointer events can arrive faster than the canvas can paint. Keep
@@ -677,6 +683,7 @@ export function useCanvasConnectionController({
cancelPendingConnectionCreate,
closeConnectionCreateMenu,
connectionTargetNodeId,
connectionApproach,
connectionTargetAnchorRatio,
connectingParams,
createConnectedNode,

View File

@@ -26,6 +26,7 @@ import {
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
@@ -182,14 +183,16 @@ export function useCanvasMediaTools({
return;
}
const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture };
const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1";
const child = createPortraitTextureNode(node, nanoid());
child.metadata = { ...child.metadata, portraitTexture: portraitTextureSettings };
setHoveredNodeId(null);
setToolbarNodeId(null);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item));
setSelectedNodeIds(new Set([node.id]));
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]);
setSelectedNodeIds(new Set([child.id]));
setSelectedConnectionId(null);
setDialogNodeId(node.id);
}, [message, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
setDialogNodeId(child.id);
}, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
if (!node.metadata?.content) return;

View File

@@ -11,6 +11,8 @@ import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { CANVAS_UPLOAD_ACCEPT, createFileUploadPlaceholder, uploadNodeType, uploadPercent } from "@/lib/canvas/canvas-file-upload";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getProjectUnit } from "@/services/api/projects";
@@ -141,36 +143,83 @@ export function useCanvasUpload({
return result.assetId;
}, [canvasId, domainProjectId, queryClient]);
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
const activeUploadsRef = useRef(new Set<string>());
const createFileNode = useCallback(async (file: File, position: Position, replaceId?: string) => {
const original = replaceId ? nodesRef.current.find((node) => node.id === replaceId) : undefined;
if (replaceId && (!original || activeUploadsRef.current.has(replaceId))) return null;
const id = replaceId || `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
activeUploadsRef.current.add(id);
const progress = startUploadStatus(replaceId ? "替换文件" : "上传文件", "读取文件信息", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const size = fitNodeSize(image.width, image.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: imageMetadata(image),
const placeholder = await createFileUploadPlaceholder(id, file, position);
setNodes((current) => replaceId ? current.map((item) => item.id === id ? {
...item, width: placeholder.width, height: placeholder.height,
metadata: { ...item.metadata, fileUpload: "uploading", fileUploadProgress: undefined, status: undefined, size: undefined, errorDetails: undefined },
} : item) : [...current, placeholder]);
selectInsertedNode(id, "close");
let lastPercent: number | undefined;
const onProgress = (loaded: number, total: number) => {
const percent = uploadPercent(loaded, total);
if (percent === undefined || percent === lastPercent) return;
lastPercent = percent;
setNodes((current) => current.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, fileUploadProgress: percent } } : item));
progress.update(percent === 100 ? "文件已传输,正在保存与处理" : `已上传 ${percent}%`, 2);
};
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
progress.update("上传文件并同步资源", 2);
let metadata: CanvasNodeData["metadata"];
if (placeholder.type === CanvasNodeType.Image) {
metadata = imageMetadata(await uploadImage(file, onProgress));
} else if (placeholder.type === CanvasNodeType.Text) {
const content = await file.text();
const resource = await uploadResourceFile(file, "file", { fileName: file.name }, onProgress);
metadata = { content, prompt: content, storageKey: resourceStorageKey(resource.id), mimeType: file.type || "text/plain", bytes: file.size, status: "success" };
} else {
const media = await uploadMediaFile(file, placeholder.type === CanvasNodeType.Video ? "video" : "audio", onProgress);
metadata = placeholder.type === CanvasNodeType.Video ? videoMetadata(media) : audioMetadata(media);
}
progress.update("更新画布节点", 3);
const currentNode = nodesRef.current.find((item) => item.id === id);
if (!currentNode) {
progress.done("上传完成,画布占位已移除");
return null;
}
const node: CanvasNodeData = {
...currentNode, type: placeholder.type,
metadata: {
...currentNode.metadata, ...metadata,
fileUpload: undefined, fileUploadProgress: undefined, errorDetails: undefined,
...(replaceId ? {
assetId: undefined, taskId: undefined, freeResize: false,
isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined,
batchFailedCount: undefined, batchUsesReferenceImages: undefined,
generationType: undefined, generationResultPlacement: undefined,
copiedFromNodeId: undefined, versionOfNodeId: undefined,
model: undefined, size: undefined, quality: undefined,
transparentBackground: undefined, count: undefined, references: undefined,
primaryImageId: undefined, imageBatchExpanded: undefined,
richText: undefined, composerContent: undefined,
} : {}),
},
};
setNodes((current) => current.map((item) => item.id === id ? { ...item, type: node.type, metadata: node.metadata } : item));
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
const localOnly = metadata.storageKey && !resourceIdFromStorageKey(metadata.storageKey);
progress.done(localOnly ? "已保存在本机,尚未上传到服务器" : persisted ? "文件已添加到画布" : "文件已添加,项目资产待重试");
if (localOnly) message.warning("文件已保存在本机,尚未上传到服务器");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "图片上传失败";
const details = error instanceof Error ? error.message : "文件上传失败";
setNodes((current) => current.map((item) => item.id !== id ? item : original?.metadata?.content ? {
...item, width: original.width, height: original.height, metadata: original.metadata,
} : { ...item, metadata: { ...item.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: details } }));
progress.fail(details);
message.error(details);
return null;
} finally {
activeUploadsRef.current.delete(id);
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
}, [domainProjectId, message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
try {
@@ -209,52 +258,6 @@ export function useCanvasUpload({
}
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "视频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "preserve");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "音频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
const trimmed = text.trim();
if (!trimmed) return false;
@@ -312,8 +315,8 @@ export function useCanvasUpload({
: target?.type === CanvasNodeType.Video
? "video/*"
: target?.type === CanvasNodeType.Audio
? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
: "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
? "audio/*,.mp3,.wav"
: target?.type === CanvasNodeType.Text ? "text/plain,text/markdown,.txt,.md,.markdown" : CANVAS_UPLOAD_ACCEPT;
}
imageInputRef.current?.click();
}, [nodesRef]);
@@ -324,9 +327,9 @@ export function useCanvasUpload({
}, []);
const handleUploadFiles = useCallback(async (files: File[]) => {
const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file));
const supportedFiles = files.filter((file) => uploadNodeType(file));
if (!supportedFiles.length) {
message.warning("请选择图片、视频、MP3 或 WAV 文件");
message.warning("请选择图片、视频、音频或 TXT / Markdown 文件");
return false;
}
const center = uploadTargetRef.current?.position || getCanvasCenter();
@@ -339,11 +342,7 @@ export function useCanvasUpload({
x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP,
y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP,
};
const createdId = await (isAudioFile(file)
? createAudioFileNode(file, position)
: file.type.startsWith("video/")
? createVideoFileNode(file, position)
: createImageFileNode(file, position));
const createdId = await createFileNode(file, position);
if (createdId) createdIds.push(createdId);
}
if (!createdIds.length) return false;
@@ -354,7 +353,7 @@ export function useCanvasUpload({
if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`);
else message.success(`已添加 ${createdIds.length} 个文件到画布`);
return true;
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
}, [createFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
// 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。
const uploadTimelineMedia = useCallback(async (files: File[]): Promise<TimelineDirectMedia[]> => {
@@ -441,89 +440,11 @@ export function useCanvasUpload({
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (!currentNode) return false;
if (isAudioFile(file)) {
const progress = startUploadStatus("替换音频", "读取音频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "preserve");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "音频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
if (file.type.startsWith("video/")) {
const progress = startUploadStatus("替换视频", "读取视频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "视频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
const progress = startUploadStatus("替换图片", "读取图片文件");
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const node = {
...currentNode,
type: CanvasNodeType.Image,
title: file.name,
metadata: {
...currentNode.metadata,
...imageMetadata(image),
assetId: undefined,
taskId: undefined,
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchFailedCount: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
transparentBackground: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
} satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "图片替换失败";
progress.fail(details);
message.error(details);
return false;
}
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
if (!currentNode || !uploadNodeType(file)) return false;
const typedNode = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(currentNode.type as CanvasNodeType);
if (typedNode && uploadNodeType(file) !== currentNode.type) return false;
return Boolean(await createFileNode(file, currentNode.position, nodeId));
}, [createFileNode, nodesRef]);
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
const isNodeMarker = (value: string) => {
@@ -536,7 +457,7 @@ export function useCanvasUpload({
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return true;
}
const inserted = await createImageFileNode(file, position || getCanvasCenter());
const inserted = await createFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return Boolean(inserted);
};
@@ -587,20 +508,17 @@ export function useCanvasUpload({
// ignore
}
return false;
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
}, [createFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
const handleImageInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
try {
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
if (!file || !uploadNodeType(file)) return;
if (target?.nodeId) {
const targetNode = nodesRef.current.find((node) => node.id === target.nodeId);
const compatible = !targetNode
|| (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
|| (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
const compatible = targetNode && (targetNode.type === uploadNodeType(file)
|| ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(targetNode.type as CanvasNodeType));
if (!compatible) {
message.warning("请选择与当前节点相同类型的媒体文件");
return;
@@ -609,12 +527,12 @@ export function useCanvasUpload({
return;
}
const position = target?.position || getCanvasCenter();
await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
await createFileNode(file, position);
} finally {
uploadTargetRef.current = null;
event.target.value = "";
}
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
}, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -635,7 +553,7 @@ export function useCanvasUpload({
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
return;
}
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
const files = Array.from(event.dataTransfer.files).filter((item) => uploadNodeType(item));
if (!files.length) return;
if (files.length > 1) {
void handleUploadFiles(files);
@@ -644,9 +562,7 @@ export function useCanvasUpload({
const file = files[0];
const position = screenToCanvas(event.clientX, event.clientY);
const target = [...nodesRef.current].reverse().find((node) => {
const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (node.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (node.type === CanvasNodeType.Audio && isAudioFile(file));
const compatible = node.type === uploadNodeType(file);
return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height;
});
if (target) {
@@ -655,8 +571,8 @@ export function useCanvasUpload({
});
return;
}
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
void createFileNode(file, position);
}, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
@@ -678,10 +594,10 @@ export function useCanvasUpload({
}, []);
const pasteAssistantImage = useCallback((file: File) => {
void createImageFileNode(file, getCanvasCenter()).then((inserted) => {
void createFileNode(file, getCanvasCenter()).then((inserted) => {
if (inserted) message.success("已从剪切板添加图片");
});
}, [createImageFileNode, getCanvasCenter, message]);
}, [createFileNode, getCanvasCenter, message]);
const openAssetsAtPosition = useCallback((position?: Position) => {
assetInsertPositionRef.current = position || null;

View File

@@ -157,10 +157,11 @@ export default function PluginsPage() {
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
const pluginSections = useMemo(
() => [
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
() =>
[
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
[categoryFilter, filteredPlugins],
);

View File

@@ -54,7 +54,6 @@ import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/
import { ModelPicker } from "@/components/model-picker";
import { 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 { navigateToSettings } from "@/lib/settings-navigation";
import {
@@ -73,7 +72,7 @@ import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
import { chapterAssetsFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterAssets, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 62;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
@@ -349,14 +348,15 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
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").map((candidate) => normalizeCharacterName(candidate.name)),
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
]);
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
const created = fresh.length
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
const storeExtractedAssets = async (unitId: string, assets: Awaited<ReturnType<typeof extractChapterAssets>>) => {
// 去重由服务端按分类、名称及别名统一完成,避免使用页面分页快照漏判。
const candidates = [
...assets.characters.map((asset) => ({ unitId, name: asset.name, category: "character" as const, details: { ...asset } })),
...assets.scenes.map((asset) => ({ unitId, name: asset.name, category: "environment" as const, details: { ...asset } })),
...assets.props.map((asset) => ({ unitId, name: asset.name, category: "prop" as const, details: { ...asset } })),
];
const created = candidates.length
? await createProjectAssetCandidates(detail.project.id, candidates, "chapter_character_extract")
: { candidates: [] };
markChapterOperationCompleted(unitId, "characters");
refreshProject();
@@ -380,16 +380,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
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);
message.info("角色、场景与道具提取任务已开始,可继续编辑或切换章节");
const assets = await extractChapterAssets(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedAssets(operationUnitId, assets);
if (!freshCount) {
message.info("本章角色已存在于待确认列表中");
message.info("没有新增资产:提取为空或资产已存在");
return;
}
message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
message.success(`已提取 ${freshCount} 个角色、场景或道具,请到项目资产确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
message.error(error instanceof Error ? error.message : "章节资产提取失败");
} finally {
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
}
@@ -477,8 +477,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
recoveringTaskIdsRef.current.add(task.id);
void queryGenerationTask(task.id).then(async (completedTask) => {
if (kind === "characters") {
await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的角色提取结果");
await storeExtractedAssets(taskChapterId, chapterAssetsFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的章节资产提取结果");
} else {
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
message.success("已恢复刷新前完成的章节分镜");
@@ -599,7 +599,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
<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={!selectedUnit || 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" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || 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={!selectedUnit || 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={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
@@ -613,7 +613,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</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 } }}>
<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>
@@ -622,7 +622,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</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 })} />
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色、场景与道具的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
</label>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
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 { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
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";
@@ -38,24 +39,28 @@ export function chapterTaskIdentity(task: GenerationTask): { chapterId: string;
return null;
}
export function chapterCharactersFromGenerationTask(task: GenerationTask) {
export function chapterAssetsFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
return parseCharacterBreakdown(result.text);
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
}
return parseChapterAssetBreakdown(result.text);
}
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
return storyboardRowsFromTask(task);
}
export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<CharacterBreakdown[]> {
export async function extractChapterAssets(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<ChapterAssetBreakdown> {
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
项目名称: input.projectName,
章节名称: input.chapterTitle,
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
章节正文: input.sourceText,
}, options);
return parseCharacterBreakdown(result);
return parseChapterAssetBreakdown(result);
}
type ChapterStoryboardGenerationInput = {
@@ -118,9 +123,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
projectId: input.projectId,
nodeId: `${operation}:${input.chapterId}`,
mode: "text",
prompt: "使用当前启用的角色卡提取模板。",
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
config: { ...input.config, model },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");

View File

@@ -251,7 +251,6 @@ export default function TasksPage() {
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
setDetailTask(detail);
setTaskLogs(logs);
if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {

View File

@@ -8,6 +8,7 @@ import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/g
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
const isActive = task.status === "queued" || task.status === "running";
@@ -22,7 +23,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
{thumbnailUrl ? (
<MediaPreview src={thumbnailUrl} kind="image" loading="lazy" className="h-full w-full object-cover" />
) : isVideo && task.previewUrl ? (
<span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>
<TaskVideoThumbnail src={task.previewUrl} />
) : (
<Icon />
)}

View File

@@ -10,6 +10,7 @@ import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskListRow({
task,
@@ -122,7 +123,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
>
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : <span className="task-video-poster-placeholder"><Video className="size-4" /><small>视频</small></span>}
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : isVideo ? <TaskVideoThumbnail src={task.previewUrl} /> : <ImageIcon className="size-4" />}
{!previewUnavailable ? (
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
import { Video } from "lucide-react";
import { MediaPreview } from "@/components/media-preview";
import { captureVideoPoster } from "@/lib/video-poster";
/** 只解码进入视口的视频,复用串行解码队列;缩略图随组件释放,不写入用户资产。 */
export function TaskVideoThumbnail({ src }: { src: string }) {
const container = useRef<HTMLSpanElement>(null);
const [poster, setPoster] = useState<{ source: string; url: string }>();
useEffect(() => {
const controller = new AbortController();
let objectUrl = "";
let started = false;
const capture = () => {
if (started || controller.signal.aborted) return;
started = true;
void captureVideoPoster(src, { signal: controller.signal, maxWidth: 400 }).then((result) => {
if (controller.signal.aborted || !result.poster) return;
objectUrl = URL.createObjectURL(result.poster);
setPoster({ source: src, url: objectUrl });
}).catch((error) => {
if (!controller.signal.aborted) console.warn("任务视频首帧提取失败", error instanceof Error ? error.name : "UnknownError");
});
};
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
capture();
}
});
if (container.current) observer.observe(container.current);
return () => {
controller.abort();
observer.disconnect();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [src]);
return <span ref={container} className="block h-full w-full">
{poster?.source === src ? <MediaPreview src={poster.url} kind="image" className="h-full w-full object-cover" /> : <span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>}
</span>;
}