"use client"; import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Typography } from "antd"; import localforage from "localforage"; import { saveAs } from "file-saver"; import { ImageSettingsPanel } from "@/components/image-settings-panel"; import { ModelPicker } from "@/components/model-picker"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; import { canvasThemes } from "@/lib/canvas-theme"; import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { nanoid } from "nanoid"; import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { requestEdit, requestGeneration } from "@/services/api/image"; import { deleteStoredImages, resolveImageUrl, uploadImage } from "@/services/image-storage"; import { useAssetStore } from "@/stores/use-asset-store"; import type { ReferenceImage } from "@/types/image"; type GeneratedImage = { id: string; dataUrl: string; storageKey?: string; durationMs: number; width: number; height: number; bytes: number; mimeType?: string; }; type GenerationResult = { id: string; status: "pending" | "success" | "failed"; image?: GeneratedImage; error?: string; }; type GenerationLog = { id: string; createdAt: number; title: string; prompt: string; time: string; model: string; config: GenerationLogConfig; references: ReferenceImage[]; durationMs: number; successCount: number; failCount: number; imageCount: number; size: string; quality: string; status: "成功" | "失败"; images: GeneratedImage[]; thumbnails: string[]; }; type GenerationLogConfig = Pick; type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; const LOG_STORE_KEY = "infinite-canvas:image_generation_logs"; const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" }); export default function ImagePage() { const { message } = App.useApp(); const fileInputRef = useRef(null); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const [prompt, setPrompt] = useState(""); const [references, setReferences] = useState([]); const [results, setResults] = useState([]); const [logs, setLogs] = useState([]); const [running, setRunning] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [startedAt, setStartedAt] = useState(0); const [elapsedMs, setElapsedMs] = useState(0); const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const model = effectiveConfig.imageModel || effectiveConfig.model; const canGenerate = Boolean(prompt.trim()); const generationCount = Math.max(1, Math.min(10, Number(config.count) || 1)); useEffect(() => { if (!running || !startedAt) return; const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000); return () => window.clearInterval(timer); }, [running, startedAt]); useEffect(() => { void refreshLogs(); }, []); const addReferences = async (files?: FileList | null) => { const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")); const nextReferences = await Promise.all( imageFiles.map(async (file) => { const image = await uploadImage(file); return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences]); }; const addReferencesFromClipboard = async () => { try { const items = await navigator.clipboard.read(); const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); if (!blobs.length) { message.error("剪切板里没有可读取的图片"); return; } const nextReferences = await Promise.all( blobs.map(async (blob, index) => { const image = await uploadImage(blob); return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; }), ); setReferences((value) => [...value, ...nextReferences]); message.success(`已读取 ${nextReferences.length} 张参考图`); } catch { message.error("剪切板里没有可读取的图片"); } }; const generate = async () => { const text = prompt.trim(); if (!text) { message.error("请输入生图提示词"); return; } if (!isAiConfigReady(effectiveConfig, model)) { message.warning("请先完成配置"); openConfigDialog(true); return; } const snapshot = buildRequestSnapshot(); if (!snapshot) return; setElapsedMs(0); setRunning(true); setPreviewLog(null); setResults(Array.from({ length: generationCount }, () => ({ id: nanoid(), status: "pending" }))); const batchStartedAt = performance.now(); setStartedAt(batchStartedAt); const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot)); const result = await Promise.allSettled(tasks); const successImages = result.filter((item): item is PromiseFulfilledResult => item.status === "fulfilled").map((item) => item.value); const successCount = successImages.length; const failCount = generationCount - successCount; const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); try { const logImages = await Promise.all( successImages.map(async (image) => { const stored = await uploadImage(image.dataUrl); return { ...image, dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType }; }), ); saveLog( buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, references: snapshot.references, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", images: logImages, }), ); successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败"); } finally { setRunning(false); } }; const downloadImage = (image: GeneratedImage, index: number) => { saveAs(image.dataUrl, `image-${index + 1}.png`); }; const addResultToReferences = async (image: GeneratedImage, index: number) => { const stored = await uploadImage(image.dataUrl); setReferences((value) => [...value, { id: nanoid(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); message.success("已加入参考图"); }; const saveResultToAssets = async (image: GeneratedImage, index: number) => { const stored = await uploadImage(image.dataUrl); addAsset({ kind: "image", title: `生成结果 ${index + 1}`, coverUrl: stored.url, tags: [], source: "生图工作台", data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType }, metadata: { source: "image-page", prompt }, }); message.success("已加入我的素材"); }; const insertPickedAsset = async (payload: InsertAssetPayload) => { if (payload.kind === "text") { setPrompt(payload.content); } else if (payload.kind === "image") { const stored = await uploadImage(payload.dataUrl); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); } else { message.warning("生图工作台只能使用文本或图片素材"); } setAssetPickerOpen(false); }; const createSession = () => { setPrompt(""); setReferences([]); setResults([]); setElapsedMs(0); setStartedAt(0); setSelectedLogIds([]); setPreviewLog(null); }; const deleteSelectedLogs = () => { const imageKeys = logs.filter((log) => selectedLogIds.includes(log.id)).flatMap((log) => log.images.map((image) => image.storageKey).filter((key): key is string => Boolean(key))); void Promise.all([deleteStoredImages(imageKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs); if (previewLog && selectedLogIds.includes(previewLog.id)) { setPreviewLog(null); setResults([]); } setSelectedLogIds([]); setDeleteConfirmOpen(false); }; const saveLog = (log: GenerationLog) => { void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs); }; const refreshLogs = async () => setLogs(await readStoredLogs()); const previewGenerationLog = async (log: GenerationLog) => { setPreviewLog(log); setLogsOpen(false); setPrompt(log.prompt); setReferences(log.references || []); if (log.config.imageModel || log.model) updateConfig("imageModel", log.config.imageModel || log.model); if (log.config.quality) updateConfig("quality", log.config.quality); if (log.config.size) updateConfig("size", log.config.size); if (log.config.count) updateConfig("count", log.config.count); setResults(log.images.map((image) => ({ id: image.id, status: "success", image }))); }; const buildRequestSnapshot = () => { const text = prompt.trim(); if (!text) { message.error("请输入生图提示词"); return null; } if (!isAiConfigReady(effectiveConfig, model)) { message.warning("请先完成配置"); openConfigDialog(true); return null; } return { text, config: { ...effectiveConfig, model, count: "1" }, references: [...references] }; }; const runGenerationSlot = async (index: number, snapshot: { text: string; config: AiConfig; references: ReferenceImage[] }) => { const itemStartedAt = performance.now(); try { const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text); const image = result[0]; if (!image) throw new Error("接口没有返回图片"); const meta = await readImageMeta(image.dataUrl); const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) }; setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage })); return nextImage; } catch (error) { setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : "生成失败" })); throw error; } }; const retryResult = (index: number) => { const snapshot = buildRequestSnapshot(); if (!snapshot) return; setPreviewLog(null); setResults((value) => updateResultAt(value, index, { status: "pending", error: undefined, image: undefined })); void runGenerationSlot(index, snapshot).catch(() => {}); }; return (

生图工作台

提示词
setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" />
参考图
{ if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return; event.preventDefault(); event.currentTarget.scrollLeft += event.deltaY; }} > {references.map((item, index) => (
{item.name} {imageReferenceLabel(index)} setReferences((value) => moveListItem(value, index, offset))} />
))} {!references.length ?
暂无参考图
: null}
{model} · {effectiveConfig.size} · {effectiveConfig.quality}

生成结果

{running ? 等待 {formatDuration(elapsedMs)} : null}
{results.length ? (
{results.map((result, index) => result.status === "success" && result.image ? ( ) : result.status === "failed" ? ( retryResult(index)} /> ) : ( ), )}
) : (
)}
{ void addReferences(event.target.files); event.target.value = ""; }} /> setLogsOpen(false)}> setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} /> setSettingsOpen(false)}>
void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除选中的 {selectedLogIds.length} 条生成记录吗?
); } function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( <>
updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" maxCount={10} />
); } function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset, }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void; }) { return (
{`生成结果
{image.width}x{image.height} {formatBytes(image.bytes)} {formatDuration(image.durationMs)}
); } function PendingImageCard() { return (
生成中
); } function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) { return (
生成失败
{error}
); } function updateResultAt(results: GenerationResult[], index: number, next: Partial) { return results.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); } function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog, }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void; }) { const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); return ( <>

生成记录

{logs.length}
{logs.map((log) => ( onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} /> ))} {!logs.length ?
暂无生成记录
: null}
); } function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { return ( ); } async function readStoredLogs() { if (typeof window === "undefined") return []; try { const values: GenerationLog[] = []; await logStore.iterate((value) => { values.push(value); }); const logs = await Promise.all(values.map(normalizeLog)); return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); } catch { return []; } } async function normalizeLog(log: Partial): Promise { const references = await Promise.all( (log.references || []).map(async (item) => ({ ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl), })), ); const images = await Promise.all( (log.images || []).map(async (item) => ({ ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl), })), ); const config = normalizeLogConfig(log); return { id: log.id || nanoid(), createdAt: log.createdAt || Date.now(), title: log.title || log.model || "未命名", prompt: log.prompt || log.title || "", time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), model: log.model || config.imageModel || "", config, references, durationMs: log.durationMs || 0, successCount: log.successCount ?? log.imageCount ?? 0, failCount: log.failCount || 0, imageCount: log.imageCount || log.successCount || 0, size: log.size || config.size || "", quality: log.quality || config.quality || "", status: log.status || "成功", images, thumbnails: images.map((image) => image.dataUrl), }; } function serializeLog(log: GenerationLog): GenerationLog { return { ...log, references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })), images: log.images.map((image) => ({ ...image, dataUrl: image.storageKey ? "" : image.dataUrl })), thumbnails: [], }; } function normalizeLogConfig(log: Partial): GenerationLogConfig { return { model: log.config?.model || log.model || "", imageModel: log.config?.imageModel || log.model || "", quality: log.config?.quality || log.quality || "", size: log.config?.size || log.size || "", count: log.config?.count || String(log.imageCount || log.successCount || 1), }; } function moveListItem(items: T[], index: number, offset: number) { const targetIndex = index + offset; if (targetIndex < 0 || targetIndex >= items.length) return items; const next = [...items]; [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; return next; } function ReferenceOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) { if (total <= 1) return null; return (
); } function buildLog({ prompt, model, config, references, durationMs, successCount, failCount, status, images, }: { prompt: string; model: string; config: GenerationLogConfig; references: ReferenceImage[]; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; images: GeneratedImage[]; }): GenerationLog { const logConfig = { model: config.model, imageModel: config.imageModel, quality: config.quality, size: config.size, count: config.count, }; return { id: nanoid(), createdAt: Date.now(), title: prompt.slice(0, 12) || "未命名", prompt, time: new Date().toLocaleString("zh-CN", { hour12: false }), model, config: logConfig, references, durationMs, successCount, failCount, imageCount: Number(logConfig.count) || successCount, size: logConfig.size, quality: logConfig.quality, status, images, thumbnails: images.map((image) => image.dataUrl), }; }