feat(image): 优化图片生成记录持久化和导出功能

- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
This commit is contained in:
HouYunFei
2026-05-26 14:44:06 +08:00
parent bf66a25936
commit 005c396660
4 changed files with 50 additions and 14 deletions
+1
View File
@@ -15,6 +15,7 @@
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name``preset=normal``input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。
@@ -3,14 +3,14 @@
import type { CSSProperties } from "react";
import { useState } from "react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play, Video } from "lucide-react";
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasSizePicker } from "./canvas-size-picker";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
@@ -118,10 +118,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</button>
</div>
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? <CanvasVideoSettingsPopover config={config} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} /> : mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
{mode === "video" ? null : <InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />}
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
) : mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : null}
</div>
<Button
@@ -317,7 +320,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Settings2 } from "lucide-react";
import { Button, Popover } from "antd";
@@ -16,30 +17,56 @@ type CanvasImageSettingsPopoverProps = {
buttonClassName?: string;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
autoAdjustOverflow?: boolean;
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft", autoAdjustOverflow = true }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const triggerRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
};
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const path = event.composedPath();
if (triggerRef.current && path.includes(triggerRef.current)) return;
if (path.some((item) => item instanceof Element && item.closest(".canvas-image-settings-popover"))) return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
}, [onOpenChange, open]);
return (
<Popover
open={open}
trigger="click"
placement={placement}
arrow={false}
overlayClassName="canvas-image-settings-popover"
color={theme.toolbar.panel}
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
onOpenChange={onOpenChange}
zIndex={1200}
autoAdjustOverflow={autoAdjustOverflow}
getPopupContainer={getPopupContainer || (() => document.body)}
onOpenChange={updateOpen}
content={<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} />}
>
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
</span>
</Button>
<span ref={triggerRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
</span>
</Button>
</span>
</Popover>
);
}
+5
View File
@@ -405,6 +405,11 @@
padding: 18px;
}
.canvas-image-settings-popover .ant-popover-inner-content {
max-height: calc(100vh - 96px);
overflow-y: auto;
}
.canvas-image-settings-popover .ant-segmented {
border-radius: 999px;
padding: 3px;