fix(*): 画布与任务详情 - 优化生成参数展示、批量上传和默认模型状态

This commit is contained in:
ddCat
2026-08-23 09:08:20 +08:00
parent d77432693e
commit 70a6640ecf
22 changed files with 1990 additions and 57 deletions

View File

@@ -26,7 +26,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
const config = useEffectiveConfig();
return (
<>
<Modal title="任务详情" open={Boolean(task)} footer={null} width={760} onCancel={onCloseTask}>
<Modal title="任务详情" open={Boolean(task)} footer={null} width="min(920px, calc(100vw - 32px))" onCancel={onCloseTask}>
{task ? (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
@@ -34,15 +34,20 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
{generationTaskShowsProgress(task) ? <TaskDetailItem label="进度" value={`${task.progress ?? 0}%`} /> : null}
<TaskDetailItem label="模型" value={task.model ? modelDisplayName(config, task.model) : "默认模型"} />
<TaskDetailItem label="任务 ID" value={task.id} />
<TaskDetailItem label="创建时间" value={formatTaskTime(task.createdAt)} />
<TaskDetailItem label="开始时间" value={formatTaskTime(task.startedAt)} />
<TaskDetailItem label="完成时间" value={formatTaskTime(task.completedAt)} />
<TaskDetailItem label="耗时" value={formatTaskDuration(task)} />
</div>
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
提示词
</div>
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>
<div className="h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>
{task.prompt || "未记录"}
</div>
</div>
<TaskGenerationParameters inputJson={task.inputJson} theme={theme} />
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
任务日志
@@ -108,3 +113,85 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</>
);
}
function TaskGenerationParameters({ inputJson, theme }: { inputJson?: string; theme: CanvasProjectStatusDialogsProps["theme"] }) {
const fields = taskParameterRows(inputJson);
return (
<div>
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>生成参数</div>
{fields.length ? (
<div className="grid grid-cols-2 gap-x-5 gap-y-1 rounded-lg border p-3 sm:grid-cols-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
{fields.map((field) => <TaskDetailItem key={field.label} label={field.label} value={field.value} />)}
</div>
) : (
<div className="rounded-lg p-3 text-xs" style={{ background: theme.node.fill, color: theme.node.muted }}>暂无参数记录</div>
)}
</div>
);
}
function taskParameterRows(inputJson?: string) {
if (!inputJson) return [] as Array<{ label: string; value: string }>;
let input: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(inputJson);
input = asRecord(parsed);
} catch {
return [] as Array<{ label: string; value: string }>;
}
const config = asRecord(input.config);
const rows: Array<{ label: string; value: string }> = [];
const add = (label: string, value: unknown) => {
if (value === undefined || value === null || value === "") return;
rows.push({ label, value: String(value) });
};
add("尺寸 / 比例", config.size);
add("分辨率", config.vquality || config.quality);
add("秒数", config.videoSeconds === undefined ? undefined : `${config.videoSeconds} 秒`);
add("生成数量", config.count);
add("生成声音", booleanLabel(config.videoGenerateAudio));
add("水印", booleanLabel(config.videoWatermark));
add("音色", config.audioVoice);
add("音频格式", config.audioFormat);
add("音频速度", config.audioSpeed);
addReference("引用图片", input.referenceImages, "图片");
addReference("引用视频", input.referenceVideos, "视频");
addReference("引用音频", input.referenceAudios, "音频");
return rows;
function addReference(label: string, value: unknown, kind: string) {
if (!Array.isArray(value) || !value.length) return;
const names = value
.map((item) => (typeof item === "object" && item !== null && "name" in item ? String((item as { name?: unknown }).name || "") : ""))
.filter(Boolean);
const suffix = names.length ? `(${names.slice(0, 3).join("、")}${names.length > 3 ? "…" : ""})` : "";
rows.push({ label, value: `${value.length} 个${kind}${suffix}` });
}
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function booleanLabel(value: unknown) {
if (value === true || value === "true") return "开启";
if (value === false || value === "false") return "关闭";
return "";
}
function formatTaskTime(value?: string) {
if (!value) return "未记录";
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : value;
}
function formatTaskDuration(task: GenerationTask) {
const start = Date.parse(task.startedAt || task.createdAt);
const end = task.completedAt ? Date.parse(task.completedAt) : task.status === "queued" || task.status === "running" ? Date.now() : Number.NaN;
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return task.completedAt ? "未记录" : "未完成";
const milliseconds = Math.max(0, end - start);
const totalSeconds = Math.round(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`;
}

View File

@@ -161,6 +161,9 @@ export function useCanvasGenerationExecutor({
taskStage: undefined,
taskProgress: undefined,
taskCreatedAt: undefined,
taskStartedAt: undefined,
taskCompletedAt: undefined,
taskDurationMs: undefined,
errorDetails: controller.signal.aborted ? undefined : errorDetails,
},
}
@@ -188,7 +191,7 @@ export function useCanvasGenerationExecutor({
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage)
setNodes((current) =>
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails } } : node)),
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)),
);
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
@@ -215,7 +218,7 @@ export function useCanvasGenerationExecutor({
}
if (controller.signal.aborted) {
if (isPreparingEmptyImage)
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined } } : node)));
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node)));
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
@@ -289,6 +292,9 @@ export function useCanvasGenerationExecutor({
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
delete metadata.taskStartedAt;
delete metadata.taskCompletedAt;
delete metadata.taskDurationMs;
return { ...node, metadata };
}),
);

View File

@@ -606,8 +606,13 @@ export function useCanvasUpload({
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
return;
}
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!file) return;
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!files.length) return;
if (files.length > 1) {
void handleUploadFiles(files);
return;
}
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/"))
@@ -622,7 +627,7 @@ 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, message, nodesRef, replaceNodeMedia, screenToCanvas]);
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;

View File

@@ -529,6 +529,9 @@ export default function TasksPage() {
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
<InfoItem label="开始时间" value={formatDate(detailTask.startedAt)} />
<InfoItem label="完成时间" value={formatDate(detailTask.completedAt)} />
<InfoItem label="耗时" value={formatTaskDuration(detailTask)} />
{detailTask.providerCancelStatus ? <InfoItem label="上游取消" value={providerCancelStatusLabel(detailTask)} /> : null}
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
</div>
@@ -548,7 +551,8 @@ export default function TasksPage() {
</div>
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{generationErrorMessage(detailTask.error)}</pre> : null}
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
<DetailBlock title="提示词" value={detailLoading ? "详情加载中..." : detailTask.prompt || "无"} tall />
<TaskParameters inputJson={detailLoading ? undefined : detailTask.inputJson} />
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
<div>
<Typography.Text strong>日志</Typography.Text>
@@ -685,28 +689,122 @@ function formatDate(value?: string) {
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}
function InfoItem({ label, value }: { label: string; value: string }) {
function formatTaskDuration(task: GenerationTask) {
if (!task.createdAt) return "-";
const start = new Date(task.startedAt || task.createdAt).getTime();
const end = task.completedAt ? new Date(task.completedAt).getTime() : task.status === "queued" || task.status === "running" ? Date.now() : Number.NaN;
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "-";
const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`;
}
function InfoItem({ label, value, wrap = false }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="task-detail-fact min-w-0 px-3 py-2.5">
<Typography.Text type="secondary" className="block text-xs">
{label}
</Typography.Text>
<Typography.Text className="block truncate text-sm" title={value}>
<Typography.Text className={`block text-sm ${wrap ? "whitespace-pre-wrap break-words" : "truncate"}`} title={value}>
{value}
</Typography.Text>
</div>
);
}
function DetailBlock({ title, value }: { title: string; value: string }) {
function DetailBlock({ title, value, tall = false }: { title: string; value: string; tall?: boolean }) {
return (
<div>
<Typography.Text strong>{title}</Typography.Text>
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100">{value}</pre>
<pre className={`mt-2 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100 ${tall ? "h-40 whitespace-pre-wrap break-words" : "max-h-60"}`}>{value}</pre>
</div>
);
}
function TaskParameters({ inputJson }: { inputJson?: string }) {
const fields = taskParameterFields(inputJson);
return (
<div>
<Typography.Text strong>参数</Typography.Text>
{fields.length ? (
<div className="task-detail-facts mt-2 grid text-sm sm:grid-cols-2">
{fields.map((field) => <InfoItem key={field.label} label={field.label} value={field.value} wrap />)}
</div>
) : (
<div className="mt-2 rounded-md bg-foreground/[.04] px-3 py-3 text-sm text-foreground/50">暂无参数记录</div>
)}
</div>
);
}
function taskParameterFields(inputJson?: string) {
const input = parseTaskInput(inputJson);
if (!input) return [];
const config = asRecord(input.config);
const fields: Array<{ label: string; value: string }> = [];
const add = (label: string, value: unknown) => {
const text = formatParameterValue(value);
if (text) fields.push({ label, value: text });
};
add("模式", input.mode);
add("尺寸 / 比例", config.size);
add("分辨率", config.vquality || config.quality);
add("时长", config.videoSeconds === undefined ? undefined : `${config.videoSeconds} 秒`);
add("生成数量", config.count);
add("生成声音", booleanParameter(config.videoGenerateAudio));
add("水印", booleanParameter(config.videoWatermark));
add("音色", config.audioVoice);
add("音频格式", config.audioFormat);
add("音频速度", config.audioSpeed);
add("参考图片", formatReferenceList(input.referenceImages, "图片"));
add("参考视频", formatReferenceList(input.referenceVideos, "视频"));
add("参考音频", formatReferenceList(input.referenceAudios, "音频"));
add("遮罩图片", formatReferenceList(input.mask ? [input.mask] : [], "遮罩"));
return fields;
}
function parseTaskInput(value?: string): Record<string, unknown> | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
return asRecord(parsed);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function formatParameterValue(value: unknown) {
if (value === undefined || value === null || value === "") return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function booleanParameter(value: unknown) {
if (value === true || value === "true") return "是";
if (value === false || value === "false") return "否";
return undefined;
}
function formatReferenceList(value: unknown, kind: string) {
if (!Array.isArray(value) || !value.length) return "无";
return value.map((item, index) => {
const reference = asRecord(item);
const name = typeof reference.name === "string" && reference.name.trim() && !/^https?:|^data:|^blob:/i.test(reference.name) ? reference.name.trim() : `${kind}${index + 1}`;
const dimensions = typeof reference.width === "number" && typeof reference.height === "number" ? `${reference.width}×${reference.height}` : "";
const duration = typeof reference.durationMs === "number" && reference.durationMs > 0 ? `${Math.round(reference.durationMs / 100) / 10}s` : "";
const details = [dimensions, duration].filter(Boolean).join(",");
return details ? `${name}(${details})` : name;
}).join("、");
}
function formatTaskJson(value?: string) {
if (!value) return "无";
try {