Files
infinite-canvas/web/src/app/(user)/image/page.tsx
T
HouYunFei 6f1e0d347b refactor(config): 将AI配置逻辑迁移至统一的状态管理store
- 移除独立的ai-config.ts文件,将其功能整合到use-config-store
- 更新所有组件导入路径从 "@/lib/ai-config" 到 "@/stores/use-config-store"
- 实现云端渠道和本地直连两种配置模式的支持
- 添加模型渠道配置管理和API请求代理转发功能
- 统一配置验证逻辑和有效配置获取方法
- 更新组件中使用的配置状态钩子和API调用方式
2026-05-21 13:36:30 +08:00

619 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { 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, AutoComplete, Button, Checkbox, Drawer, Empty, Image, Input, InputNumber, Modal, Select, Tag, Typography } from "antd";
import localforage from "localforage";
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 { isAiConfigReady, useConfigStore, useEffectiveAiConfig, type AiConfig } from "@/stores/use-config-store";
import { createId } from "@/lib/id";
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { requestEdit, requestGeneration } from "@/services/api/image";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
type GeneratedImage = {
id: string;
dataUrl: string;
durationMs: number;
width: number;
height: number;
bytes: number;
};
type GenerationResult = {
id: string;
status: "pending" | "success" | "failed";
image?: GeneratedImage;
error?: string;
};
type GenerationLog = {
id: string;
createdAt: number;
title: string;
time: string;
model: string;
durationMs: number;
successCount: number;
failCount: number;
imageCount: number;
size: string;
quality: string;
status: "成功" | "失败";
thumbnails: string[];
};
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
const qualityOptions = ["auto", "low", "medium", "high"].map((value) => ({ label: value, value }));
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
export default function ImagePage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveAiConfig(config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const addAsset = useAssetStore((state) => state.addAsset);
const [prompt, setPrompt] = useState("");
const [references, setReferences] = useState<ReferenceImage[]>([]);
const [results, setResults] = useState<GenerationResult[]>([]);
const [logs, setLogs] = useState<GenerationLog[]>([]);
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<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(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: createId(), 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: createId(), 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: createId(), 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<GeneratedImage> => 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 {
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) }));
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
} finally {
setRunning(false);
}
};
const downloadImage = (image: GeneratedImage, index: number) => {
const link = document.createElement("a");
link.href = image.dataUrl;
link.download = `image-${index + 1}.png`;
link.click();
};
const addResultToReferences = async (image: GeneratedImage, index: number) => {
const stored = await uploadImage(image.dataUrl);
setReferences((value) => [...value, { id: createId(), 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 {
const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: createId(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
}
setAssetPickerOpen(false);
};
const createSession = () => {
setPrompt("");
setReferences([]);
setResults([]);
setElapsedMs(0);
setStartedAt(0);
setSelectedLogIds([]);
setPreviewLog(null);
};
const deleteSelectedLogs = () => {
void Promise.all(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, log).then(refreshLogs);
};
const refreshLogs = async () => setLogs(await readStoredLogs());
const previewGenerationLog = async (log: GenerationLog) => {
setPreviewLog(log);
setLogsOpen(false);
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => {
const meta = await readImageMeta(dataUrl);
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
}));
setResults(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 (
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
</aside>
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
<div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
</div>
<div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}></Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</div>
</div>
</div>
<div className="mt-6 space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}></Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}></Button>
</div>
</div>
<Input.TextArea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={7}
placeholder="描述画面主体、风格、构图、光线和用途"
/>
</div>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}></Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}></Button>
</div>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault();
event.currentTarget.scrollLeft += event.deltaY;
}}>
{references.map((item) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
<Trash2 className="size-3.5" />
</button>
</div>
))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"></div> : null}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {effectiveConfig.size} · {effectiveConfig.quality}</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</div>
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</div>
<div className="mt-auto pt-6">
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
</Button>
</div>
</div>
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold"></h2>
</div>
{running ? <Tag className="m-0 px-2 py-1"> {formatDuration(elapsedMs)}</Tag> : null}
</div>
{results.length ? (
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{results.map((result, index) => (
result.status === "success" && result.image ? (
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
) : result.status === "failed" ? (
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
) : (
<PendingImageCard key={result.id} />
)
))}
</div>
) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
<ImagePlus className="mb-4 size-11 text-stone-400" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成图片" />
</div>
)}
</div>
</section>
</main>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => {
void addReferences(event.target.files);
event.target.value = "";
}} />
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
</Drawer>
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3">
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedLogIds.length}
</Modal>
</div>
);
}
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
return (
<>
<label className="col-span-2 block min-w-0 sm:col-span-1">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<InputNumber className="canvas-control-number !w-full" min={1} max={10} value={Number(config.count) || 1} onChange={(value) => updateConfig("count", String(value || 1))} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<AutoComplete className="canvas-control-select w-full" value={config.size} options={sizeOptions} placeholder="例如 1:1、3:2" onChange={(value) => updateConfig("size", value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<Select className="canvas-control-select w-full" value={config.quality} options={qualityOptions} onChange={(value) => updateConfig("quality", value)} />
</label>
</>
);
}
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 (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
<span>{image.width}x{image.height}</span>
<span>{formatBytes(image.bytes)}</span>
<span>{formatDuration(image.durationMs)}</span>
</div>
<div className="flex shrink-0 gap-1">
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}></Button>
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}></Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}></Button>
</div>
</div>
</div>
);
}
function PendingImageCard() {
return (
<div className="relative aspect-square overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<LoaderCircle className="size-6 animate-spin" />
<span></span>
</div>
</div>
);
}
function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
<div className="flex aspect-square flex-col items-center justify-center gap-3 p-5 text-center">
<div className="text-sm font-medium text-red-600 dark:text-red-300"></div>
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
{error}
</Typography.Paragraph>
</div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}></Button>
</div>
</div>
);
}
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
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 (
<>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold"></h2>
</div>
<Tag className="m-0">{logs.length}</Tag>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}></Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}></Button>
</div>
<div className="space-y-3">
{logs.map((log) => (
<LogCard
key={log.id}
log={log}
selected={selectedLogIds.includes(log.id)}
active={activeLogId === log.id}
onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))}
onClick={() => onPreviewLog(log)}
/>
))}
{!logs.length ? (
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
</div>
) : null}
</div>
</>
);
}
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
return (
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
{log.thumbnails?.length ? (
<div className="mt-2 flex gap-1 overflow-hidden">
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)}
</div>
) : null}
</div>
</div>
<div className="grid justify-items-end gap-2">
<div className="flex gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue"> {log.successCount ?? log.imageCount}</Tag>
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red"> {log.failCount}</Tag> : null}
</div>
<div className="flex flex-wrap justify-end gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} </Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag>
</div>
<div className="flex justify-end">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
</div>
</div>
</div>
</button>
);
}
async function readStoredLogs() {
if (typeof window === "undefined") return [];
try {
const legacyValue = window.localStorage.getItem(LOG_STORE_KEY);
if (legacyValue) {
await Promise.all((JSON.parse(legacyValue) as GenerationLog[]).map((log) => logStore.setItem(log.id, normalizeLog(log))));
window.localStorage.removeItem(LOG_STORE_KEY);
}
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
await Promise.all(legacyKeys.map(async (key) => {
try {
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
await logStore.setItem(log.id, log);
window.localStorage.removeItem(key);
} catch {}
}));
const logs: GenerationLog[] = [];
await logStore.iterate<GenerationLog, void>((value) => {
logs.push(normalizeLog(value));
});
return logs
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch {
return [];
}
}
function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
return {
id: log.id || createId(),
createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
model: log.model || "",
durationMs: log.durationMs || 0,
successCount: log.successCount ?? log.imageCount ?? 0,
failCount: log.failCount || 0,
imageCount: log.imageCount || log.successCount || 0,
size: log.size || "",
quality: log.quality || "",
status: log.status || "成功",
thumbnails: log.thumbnails || [],
};
}
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog {
return {
id: createId(),
createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名",
time: new Date().toLocaleString("zh-CN", { hour12: false }),
model,
durationMs,
successCount,
failCount,
imageCount: Number(config.count) || successCount,
size: config.size,
quality: config.quality,
status,
thumbnails,
};
}