mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
refactor: remove "use client" directive from multiple files and update project structure to align with Vite and React Router
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { createZip, readZip } from "@/lib/zip";
|
||||
import { getMediaBlob, setMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob, setImageBlob } from "@/services/image-storage";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type AssetExportFile = {
|
||||
app: "infinite-canvas";
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
assets: Asset[];
|
||||
files: AssetExportItem[];
|
||||
};
|
||||
|
||||
type AssetExportItem = {
|
||||
storageKey: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export async function exportAssets(assets: Asset[]) {
|
||||
const files: AssetExportItem[] = [];
|
||||
const zipFiles: { name: string; data: BlobPart }[] = [];
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.kind !== "image" && asset.kind !== "video") return;
|
||||
const storageKey = asset.data.storageKey;
|
||||
if (!storageKey) return;
|
||||
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||||
if (!blob) return;
|
||||
const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`;
|
||||
files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size });
|
||||
zipFiles.push({ name: path, data: blob });
|
||||
}),
|
||||
);
|
||||
|
||||
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
||||
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, "我的素材.zip");
|
||||
}
|
||||
|
||||
export async function readAssetPackage(file: File) {
|
||||
const zip = await readZip(file);
|
||||
const assetFile = zip.get("assets.json");
|
||||
if (!assetFile) throw new Error("missing assets.json");
|
||||
const data = JSON.parse(await assetFile.text()) as AssetExportFile;
|
||||
await Promise.all(
|
||||
data.files.map(async (item) => {
|
||||
const blob = zip.get(item.path);
|
||||
if (!blob) return;
|
||||
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
|
||||
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
|
||||
}),
|
||||
);
|
||||
return data.assets;
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
|
||||
function fileExtension(mimeType: string, kind: Asset["kind"]) {
|
||||
if (mimeType.includes("png")) return "png";
|
||||
if (mimeType.includes("jpeg")) return "jpg";
|
||||
if (mimeType.includes("webp")) return "webp";
|
||||
if (mimeType.includes("gif")) return "gif";
|
||||
if (mimeType.includes("mp4")) return "mp4";
|
||||
if (mimeType.includes("webm")) return "webm";
|
||||
return kind === "image" ? "png" : "bin";
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
|
||||
type AssetFormValues = {
|
||||
kind: AssetKind;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
source?: string;
|
||||
note?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type ImageDraft = ImageAsset["data"] | null;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
];
|
||||
|
||||
export default function AssetsPage() {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const assetInputRef = useRef<HTMLInputElement>(null);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||
const removeAsset = useAssetStore((state) => state.removeAsset);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||
const [isAssetOpen, setIsAssetOpen] = useState(false);
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
const coverUrl = Form.useWatch("coverUrl", form) || "";
|
||||
const title = Form.useWatch("title", form) || "";
|
||||
const tags = Form.useWatch("tags", form) || [];
|
||||
const content = Form.useWatch("content", form) || "";
|
||||
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image" || asset.kind === "video"), [assets]);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return validAssets.filter((asset) => {
|
||||
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
|
||||
if (!query) return true;
|
||||
return assetSearchText(asset).includes(query);
|
||||
});
|
||||
}, [validAssets, keyword, kindFilter]);
|
||||
|
||||
const visibleAssets = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredAssets.slice(start, start + pageSize);
|
||||
}, [filteredAssets, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
|
||||
setPage((value) => Math.min(value, maxPage));
|
||||
}, [filteredAssets.length, pageSize]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAsset(null);
|
||||
setImageDraft(null);
|
||||
setFormKind("text");
|
||||
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (asset: Asset) => {
|
||||
setEditingAsset(asset);
|
||||
setFormKind(asset.kind);
|
||||
setImageDraft(asset.kind === "image" ? asset.data : null);
|
||||
form.setFieldsValue({
|
||||
kind: asset.kind,
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags || [],
|
||||
source: asset.source,
|
||||
note: asset.note,
|
||||
content: asset.kind === "text" ? asset.data.content : "",
|
||||
});
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
const saveAsset = async () => {
|
||||
const values = await form.validateFields();
|
||||
const base = {
|
||||
title: values.title.trim(),
|
||||
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
|
||||
tags: values.tags || [],
|
||||
source: values.source?.trim(),
|
||||
note: values.note?.trim(),
|
||||
metadata: editingAsset?.metadata || { source: "manual" },
|
||||
};
|
||||
|
||||
if (values.kind === "text") {
|
||||
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
} else {
|
||||
if (!imageDraft) {
|
||||
message.error("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
const asset = { ...base, kind: "image" as const, data: imageDraft };
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
const readCoverFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
form.setFieldValue("coverUrl", dataUrl);
|
||||
};
|
||||
|
||||
const readImageFile = async (file?: File) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const image = await uploadImage(file);
|
||||
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
|
||||
setImageDraft(draft);
|
||||
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
|
||||
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
|
||||
};
|
||||
|
||||
const copyAssetText = async (asset: Asset) => {
|
||||
if (asset.kind !== "text") return;
|
||||
copyText(asset.data.content, "文本已复制");
|
||||
};
|
||||
|
||||
const downloadImage = (asset: Asset) => {
|
||||
if (asset.kind !== "image" && asset.kind !== "video") return;
|
||||
saveAs(asset.kind === "video" ? asset.data.url : asset.data.dataUrl, `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`);
|
||||
};
|
||||
|
||||
const exportAllAssets = async () => {
|
||||
if (!validAssets.length) {
|
||||
message.warning("暂无素材可导出");
|
||||
return;
|
||||
}
|
||||
await exportAssets(validAssets);
|
||||
};
|
||||
|
||||
const importAssetZip = async (file?: File) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const importedAssets = await readAssetPackage(file);
|
||||
importedAssets.forEach((asset) => {
|
||||
const payload = { ...asset } as Record<string, unknown>;
|
||||
delete payload.id;
|
||||
delete payload.createdAt;
|
||||
delete payload.updatedAt;
|
||||
addAsset(payload as Parameters<typeof addAsset>[0]);
|
||||
});
|
||||
message.success(`已导入 ${importedAssets.length} 个素材`);
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的素材压缩包");
|
||||
} finally {
|
||||
if (assetInputRef.current) assetInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input.Search
|
||||
className="w-full"
|
||||
size="large"
|
||||
allowClear
|
||||
prefix={<Search className="size-4 text-stone-400" />}
|
||||
value={keyword}
|
||||
placeholder="搜索标题、内容、标签或来源"
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
setPage(1);
|
||||
setKeyword(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
|
||||
<div className="text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kindOptions.map((option) => (
|
||||
<Tag.CheckableTag
|
||||
key={option.value}
|
||||
checked={kindFilter === option.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setKindFilter(option.value as AssetKind | "all");
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => void exportAllAssets()}
|
||||
>
|
||||
导出素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => assetInputRef.current?.click()}
|
||||
>
|
||||
导入素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增素材
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredAssets.length}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
<Select
|
||||
options={[
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
]}
|
||||
onChange={(value) => setFormKind(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
|
||||
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Form.Item name="source" label="来源">
|
||||
<Input placeholder="手动添加 / 画布 / 提示词库" />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{formKind === "text" ? (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||||
选择图片文件
|
||||
</Button>
|
||||
{imageDraft ? (
|
||||
<Typography.Text type="secondary" className="ml-3 text-xs">
|
||||
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary" className="ml-3 text-xs">
|
||||
未选择图片
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
{coverUrl || imageDraft?.dataUrl ? (
|
||||
<img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
</Typography.Text>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tags.length ? (
|
||||
tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag className="m-0">未打标签</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void readCoverFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void readImageFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
|
||||
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
const summary = assetSummary(asset);
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? (
|
||||
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||||
<Typography.Text type="secondary" className="mt-1 block text-xs">
|
||||
{asset.source || "未标注来源"}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(asset.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} className="m-0 text-[11px]">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{!asset.tags?.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button size="small" onClick={onOpen}>
|
||||
查看
|
||||
</Button>
|
||||
{asset.kind !== "video" ? (
|
||||
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{asset.kind === "text" ? (
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>
|
||||
复制
|
||||
</Button>
|
||||
) : null}
|
||||
{asset.kind === "image" || asset.kind === "video" ? (
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>
|
||||
下载
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||
return (
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? (
|
||||
<Image src={cover} alt={asset.title} className="rounded-lg" />
|
||||
) : (
|
||||
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
{asset.title}
|
||||
</Typography.Title>
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag>
|
||||
{(asset.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<Typography.Text type="secondary" className="block text-xs">
|
||||
内容
|
||||
</Typography.Text>
|
||||
{asset.kind === "text" ? (
|
||||
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
|
||||
) : asset.kind === "video" ? (
|
||||
<video src={asset.data.url} controls className="mt-2 aspect-video w-full rounded-lg bg-black" />
|
||||
) : (
|
||||
<Typography.Text className="mt-2 block">
|
||||
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{asset.note ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">备注</Typography.Text>
|
||||
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
|
||||
</div>
|
||||
) : null}
|
||||
<Space>
|
||||
{asset.kind === "text" ? (
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
|
||||
复制文本
|
||||
</Button>
|
||||
) : null}
|
||||
{asset.kind === "image" || asset.kind === "video" ? (
|
||||
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
|
||||
{asset.kind === "video" ? "下载视频" : "下载图片"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function assetSummary(asset: Asset) {
|
||||
if (asset.kind === "text") return asset.data.content;
|
||||
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
}
|
||||
|
||||
function assetSearchText(asset: Asset) {
|
||||
return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button } from "antd";
|
||||
import { Download, FileUp, Plus } from "lucide-react";
|
||||
|
||||
import { readZip } from "@/lib/zip";
|
||||
import { setMediaBlob } from "@/services/file-storage";
|
||||
import { setImageBlob } from "@/services/image-storage";
|
||||
import { CanvasDeleteProjectsDialog } from "@/components/canvas/canvas-delete-projects-dialog";
|
||||
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
|
||||
import type { CanvasExportFile } from "@/types/canvas-export";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
|
||||
export default function CanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const autoOpenRef = useRef(false);
|
||||
const hydrated = useCanvasStore((state) => state.hydrated);
|
||||
const projects = useCanvasStore((state) => state.projects);
|
||||
const createProject = useCanvasStore((state) => state.createProject);
|
||||
const importProject = useCanvasStore((state) => state.importProject);
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
|
||||
const mode = searchParams.get("mode");
|
||||
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
|
||||
const agentQuery = agentMode ? `?${searchParams.toString()}` : "";
|
||||
const enterProject = (id: string) => {
|
||||
navigate(`/canvas/${id}${agentQuery}`);
|
||||
};
|
||||
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
|
||||
const importCanvas = async (file?: File) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const zip = await readZip(file);
|
||||
const projectFile = zip.get("projects.json");
|
||||
if (!projectFile) throw new Error("missing projects.json");
|
||||
const data = JSON.parse(await projectFile.text()) as CanvasExportFile;
|
||||
await Promise.all(
|
||||
data.projects.flatMap((project) =>
|
||||
project.files.map(async (item) => {
|
||||
const blob = zip.get(item.path);
|
||||
if (!blob) return;
|
||||
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
|
||||
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
|
||||
}),
|
||||
),
|
||||
);
|
||||
data.projects.forEach((item) => importProject(item.project));
|
||||
message.success(`已导入 ${data.projects.length} 个画布`);
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的画布压缩包");
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || autoOpenRef.current || (mode !== "new" && mode !== "recent")) return;
|
||||
autoOpenRef.current = true;
|
||||
enterProject(mode === "new" ? createProject(`无限画布 ${projects.length + 1}`) : projects[0]?.id || createProject(`无限画布 ${projects.length + 1}`));
|
||||
}, [createProject, hydrated, mode, projects]);
|
||||
|
||||
if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">画布库</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold">无限画布</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length ? (
|
||||
<>
|
||||
<Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}>
|
||||
导出选中
|
||||
</Button>
|
||||
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
|
||||
删除选中
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{projects.length ? (
|
||||
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
|
||||
删除全部
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
|
||||
导入画布
|
||||
</Button>
|
||||
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!hydrated ? (
|
||||
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">正在加载画布...</section>
|
||||
) : projects.length ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<CanvasProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
|
||||
<h2 className="text-xl font-medium">还没有画布</h2>
|
||||
<p className="mt-3 text-sm text-stone-500">新建一个画布后,就可以独立保存节点、连线和画布外观。</p>
|
||||
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||
新建画布
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
|
||||
<CanvasDeleteProjectsDialog />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { App, Button, Image, Tag } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { fetchPrompts, type Prompt } from "@/services/api/prompts";
|
||||
import { navigationTools } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="relative inline-block px-1">
|
||||
{action === "highlight" ? (
|
||||
<span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
|
||||
)}
|
||||
<span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [primaryTool] = navigationTools;
|
||||
const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPrompts({ pageSize: 12 })
|
||||
.then((data) => setPromptShowcase(data.items))
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<main className="relative h-full overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] text-stone-950 dark:bg-[radial-gradient(rgba(245,245,244,.18)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="relative mx-auto min-h-[calc(100vh-4rem)] max-w-7xl overflow-hidden px-6">
|
||||
<div className="pointer-events-none absolute left-[15%] top-24 size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||
|
||||
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
|
||||
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">无限画布</h1>
|
||||
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
|
||||
在
|
||||
<Highlighter action="underline" color="#FF9800">
|
||||
无限画布
|
||||
</Highlighter>
|
||||
中生成、连接和重组
|
||||
<Highlighter action="highlight" color="#87CEFA">
|
||||
图片、文字与图形
|
||||
</Highlighter>
|
||||
,让创作从单次生成变成连续推演。
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="primary" size="large" onClick={() => navigate(`/${primaryTool.slug}`)} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
开始使用
|
||||
</Button>
|
||||
<Button size="large" onClick={() => navigate("/canvas")}>
|
||||
打开画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="relative mx-auto mb-20 max-w-6xl border-t border-stone-200 pt-12 dark:border-stone-800">
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
|
||||
<div />
|
||||
<div className="max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">沉淀每一次好结果</h2>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。</p>
|
||||
</div>
|
||||
<Button type="link" onClick={() => navigate("/prompts")} className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
查看提示词库
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
|
||||
{promptShowcase.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreviewIndex(index);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
"group relative cursor-pointer overflow-hidden border border-stone-200 bg-stone-100 text-left dark:border-stone-800 dark:bg-stone-900",
|
||||
index === 0 && "md:col-span-2 md:row-span-2",
|
||||
index === 3 && "md:col-span-2",
|
||||
)}
|
||||
>
|
||||
<img src={item.coverUrl} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent p-4 text-white">
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{item.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="filled" className="m-0 bg-white/15 text-[11px] text-white backdrop-blur">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-white/75">{item.prompt}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
open: previewOpen,
|
||||
current: previewIndex,
|
||||
onOpenChange: setPreviewOpen,
|
||||
onChange: setPreviewIndex,
|
||||
}}
|
||||
>
|
||||
<div className="hidden">
|
||||
{promptShowcase.map((item) => (
|
||||
<Image key={item.id} src={item.coverUrl} alt={item.title} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
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, Tooltip, 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 "@/components/canvas/asset-picker-modal";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { modelOptionLabel, 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<AiConfig, "model" | "imageModel" | "quality" | "size" | "count">;
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
||||
const RESULT_ACTION_BUTTON_CLASS = "min-w-0 px-1.5 [&_.ant-btn-icon]:shrink-0 [&>span:last-child]:min-w-0 [&>span:last-child]:truncate";
|
||||
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 = 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<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: 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<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 {
|
||||
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 (
|
||||
<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, index) => (
|
||||
<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" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{imageReferenceLabel(index)}</span>
|
||||
<ReferenceOrderButtons index={index} total={references.length} onMove={(offset) => setReferences((value) => moveListItem(value, index, offset))} />
|
||||
<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">
|
||||
{modelOptionLabel(effectiveConfig, 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="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||
<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 }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
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)} capability="image" fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<ImageSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" maxCount={10} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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="space-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
||||
<div className="flex min-w-0 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="grid min-w-0 grid-cols-3 gap-2">
|
||||
<Tooltip title="添加到素材">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||
添加到素材
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="加入参考图">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>
|
||||
加入参考图
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="下载">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>
|
||||
下载
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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 }) {
|
||||
const thumbnails = (log.thumbnails || []).filter(Boolean).slice(0, 4);
|
||||
|
||||
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>
|
||||
{thumbnails.length ? (
|
||||
<div className="mt-2 flex gap-1 overflow-hidden">
|
||||
{thumbnails.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 values: GenerationLog[] = [];
|
||||
await logStore.iterate<GenerationLog, void>((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<GenerationLog>): Promise<GenerationLog> {
|
||||
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).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
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<GenerationLog>): 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<T>(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 (
|
||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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).filter(Boolean),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Home } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">这个地址没有对应的页面,可能已经移动或被合并到其他入口。</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link to="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Copy, FolderPlus } from "lucide-react";
|
||||
import { Button, Modal, Space, Tag } from "antd";
|
||||
|
||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
||||
{prompt ? (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
|
||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
|
||||
创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}
|
||||
</div>
|
||||
<Space wrap className="mt-5">
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { FolderPlus, Search } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import { App, Button, Empty, Input, Spin, Tag } from "antd";
|
||||
|
||||
import { PromptCard } from "@/components/prompts/prompt-card";
|
||||
import { usePromptList } from "@/components/prompts/use-prompt-list";
|
||||
import { PromptDetailDialog } from "./components/prompt-detail-dialog";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [titleKeyword, setTitleKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const copyText = useCopyText();
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||
};
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||
<main
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
|
||||
onScroll={handleListScroll}
|
||||
>
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
||||
</div>
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-60 items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isLoading ? (
|
||||
<>
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||
</div>
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">分类</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{promptCategoryOptions.map((category) => (
|
||||
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
||||
{category}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{promptTags.map((tag) => (
|
||||
<Tag.CheckableTag
|
||||
key={tag}
|
||||
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
|
||||
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
|
||||
onChange={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!query.isLoading ? (
|
||||
<div>
|
||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{promptItems.map((item) => (
|
||||
<PromptCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={() => setSelectedPrompt(item)}
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
|
||||
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
|
||||
import localforage from "localforage";
|
||||
import { nanoid } from "nanoid";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, formatDuration } from "@/lib/image-utils";
|
||||
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
type GeneratedVideo = {
|
||||
id: string;
|
||||
url: string;
|
||||
storageKey: string;
|
||||
durationMs: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
type GenerationResult = {
|
||||
id: string;
|
||||
status: "pending" | "success" | "failed";
|
||||
video?: GeneratedVideo;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GenerationLog = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
time: string;
|
||||
model: string;
|
||||
config: GenerationLogConfig;
|
||||
references: ReferenceImage[];
|
||||
videoReferences: ReferenceVideo[];
|
||||
audioReferences: ReferenceAudio[];
|
||||
durationMs: number;
|
||||
size: string;
|
||||
resolution: string;
|
||||
seconds: string;
|
||||
status: "生成中" | "成功" | "失败";
|
||||
task?: VideoGenerationTask;
|
||||
video?: GeneratedVideo;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GenerationLogConfig = Pick<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs";
|
||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" });
|
||||
|
||||
export default function VideoPage() {
|
||||
const { message } = App.useApp();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeLogIdsRef = useRef<Set<string>>(new Set());
|
||||
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<ReferenceImage[]>([]);
|
||||
const [videoReferences, setVideoReferences] = useState<ReferenceVideo[]>([]);
|
||||
const [audioReferences, setAudioReferences] = useState<ReferenceAudio[]>([]);
|
||||
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.videoModel || effectiveConfig.model;
|
||||
const canGenerate = Boolean(prompt.trim());
|
||||
|
||||
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 selectedFiles = Array.from(files || []);
|
||||
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考素材,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
|
||||
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
|
||||
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
|
||||
if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图");
|
||||
if (selectedFiles.some((file) => file.type.startsWith("video/") && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 50MB 的参考视频");
|
||||
if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频");
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
const nextVideoReferences = await Promise.all(
|
||||
videoFiles.map(async (file) => {
|
||||
const video = await uploadMediaFile(file, "video-reference");
|
||||
return { id: nanoid(), name: file.name, type: video.mimeType, url: video.url, storageKey: video.storageKey, bytes: video.bytes, width: video.width, height: video.height, durationMs: video.durationMs };
|
||||
}),
|
||||
);
|
||||
const nextAudioReferences = filterAudioReferencesByDuration(
|
||||
audioReferences,
|
||||
await Promise.all(
|
||||
audioFiles.map(async (file) => {
|
||||
const audio = await uploadMediaFile(file, "audio-reference");
|
||||
return { id: nanoid(), name: file.name, type: audio.mimeType, url: audio.url, storageKey: audio.storageKey, durationMs: audio.durationMs };
|
||||
}),
|
||||
),
|
||||
message.warning,
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
setVideoReferences((value) => [...value, ...nextVideoReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
|
||||
setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios));
|
||||
};
|
||||
|
||||
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.slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length).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].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||
} catch {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
}
|
||||
};
|
||||
const generate = async () => {
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
setPreviewLog(null);
|
||||
setResults([{ id: nanoid(), status: "pending" }]);
|
||||
const batchStartedAt = performance.now();
|
||||
setStartedAt(batchStartedAt);
|
||||
try {
|
||||
const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences);
|
||||
const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task });
|
||||
await saveLog(log);
|
||||
void pollGenerationLog(log, snapshot.config);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
||||
await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||
message.error(errorMessage);
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildRequestSnapshot = () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.error("请输入视频提示词");
|
||||
return null;
|
||||
}
|
||||
if (!isAiConfigReady(effectiveConfig, model)) {
|
||||
message.warning("请先完成配置");
|
||||
openConfigDialog(true);
|
||||
return null;
|
||||
}
|
||||
const videoReferenceError = seedanceVideoReferenceError(videoReferences);
|
||||
if (videoReferenceError) {
|
||||
message.error(`${videoReferenceError}。${seedanceVideoReferenceHint}`);
|
||||
return null;
|
||||
}
|
||||
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences], audioReferences: [...audioReferences] };
|
||||
};
|
||||
|
||||
const retryResult = () => {
|
||||
void generate();
|
||||
};
|
||||
|
||||
const downloadVideo = (video: GeneratedVideo) => {
|
||||
saveAs(video.url, "video.mp4");
|
||||
};
|
||||
|
||||
const saveResultToAssets = (video: GeneratedVideo) => {
|
||||
addAsset({
|
||||
kind: "video",
|
||||
title: "生成视频",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
source: "视频创作台",
|
||||
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
|
||||
metadata: { source: "video-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 }].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
} else if (payload.kind === "video") {
|
||||
setVideoReferences((value) => [...value, { id: nanoid(), name: payload.title, type: "video/mp4", url: payload.url, storageKey: payload.storageKey, width: payload.width, height: payload.height }].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
|
||||
const createSession = () => {
|
||||
setPrompt("");
|
||||
setReferences([]);
|
||||
setVideoReferences([]);
|
||||
setAudioReferences([]);
|
||||
setResults([]);
|
||||
setElapsedMs(0);
|
||||
setStartedAt(0);
|
||||
setSelectedLogIds([]);
|
||||
setPreviewLog(null);
|
||||
};
|
||||
|
||||
const deleteSelectedLogs = () => {
|
||||
const mediaKeys = logs
|
||||
.filter((log) => selectedLogIds.includes(log.id))
|
||||
.map((log) => log.video?.storageKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
|
||||
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||
setPreviewLog(null);
|
||||
setResults([]);
|
||||
}
|
||||
setSelectedLogIds([]);
|
||||
setDeleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
const saveLog = async (log: GenerationLog) => {
|
||||
await logStore.setItem(log.id, serializeLog(log));
|
||||
await refreshLogs();
|
||||
};
|
||||
|
||||
const refreshLogs = async () => {
|
||||
const nextLogs = await readStoredLogs();
|
||||
setLogs(nextLogs);
|
||||
resumePendingLogs(nextLogs);
|
||||
return nextLogs;
|
||||
};
|
||||
|
||||
const resumePendingLogs = (items: GenerationLog[]) => {
|
||||
for (const log of items) {
|
||||
if (log.status === "生成中" && log.task) void pollGenerationLog(log);
|
||||
}
|
||||
};
|
||||
|
||||
const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => {
|
||||
if (!log.task || activeLogIdsRef.current.has(log.id)) return;
|
||||
activeLogIdsRef.current.add(log.id);
|
||||
setRunning(true);
|
||||
setStartedAt((value) => value || performance.now());
|
||||
setResults((value) => (value.length ? value : [{ id: log.id, status: "pending" }]));
|
||||
const taskConfig = buildVideoConfig({ ...effectiveConfig, ...log.config }, log.task.model || log.model);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const state = await pollVideoGenerationTask(configOverride || taskConfig, log.task);
|
||||
if (state.status === "completed") {
|
||||
const stored = await storeGeneratedVideo(state.result);
|
||||
const nextVideo: GeneratedVideo = {
|
||||
id: nanoid(),
|
||||
url: stored.url,
|
||||
storageKey: stored.storageKey,
|
||||
durationMs: Date.now() - log.createdAt,
|
||||
width: stored.width || 1280,
|
||||
height: stored.height || 720,
|
||||
bytes: stored.bytes,
|
||||
mimeType: stored.mimeType,
|
||||
};
|
||||
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
||||
await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined });
|
||||
message.success("视频已生成");
|
||||
return;
|
||||
}
|
||||
if (state.status === "failed") throw new Error(state.error);
|
||||
if (attempt === 119) throw new Error("视频生成超时,请稍后重试");
|
||||
await delay(log.task.provider === "seedance" ? 5000 : 2500);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: log.id, status: "failed", error: errorMessage }]);
|
||||
await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage });
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
activeLogIdsRef.current.delete(log.id);
|
||||
if (!activeLogIdsRef.current.size) {
|
||||
setRunning(false);
|
||||
setStartedAt(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previewGenerationLog = (log: GenerationLog) => {
|
||||
setPreviewLog(log);
|
||||
setLogsOpen(false);
|
||||
setPrompt(log.prompt);
|
||||
setReferences(log.references || []);
|
||||
setVideoReferences(log.videoReferences || []);
|
||||
setAudioReferences(log.audioReferences || []);
|
||||
if (log.config.videoModel || log.model) updateConfig("videoModel", log.config.videoModel || log.model);
|
||||
if (log.config.size) updateConfig("size", log.config.size);
|
||||
if (log.config.vquality) updateConfig("vquality", log.config.vquality);
|
||||
if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds);
|
||||
if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio);
|
||||
if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark);
|
||||
setResults(log.status === "生成中" ? [{ id: log.id, status: "pending" }] : log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
|
||||
};
|
||||
|
||||
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={previewGenerationLog} />
|
||||
</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 className="flex items-start justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">视频创作台</h1>
|
||||
<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 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">
|
||||
{references.map((item, index) => (
|
||||
<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" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("image", index)}</span>
|
||||
<ReferenceOrderButtons index={index} total={references.length} onMove={(offset) => setReferences((value) => moveListItem(value, index, offset))} />
|
||||
<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">暂无参考图,最多 9 张</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">参考视频</span>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</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">
|
||||
{videoReferences.map((item, index) => (
|
||||
<div key={item.id} className="group relative h-20 w-32 shrink-0 overflow-hidden rounded-md border border-stone-200 bg-black dark:border-stone-800">
|
||||
<video src={item.url} className="size-full object-cover" muted preload="metadata" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">{seedanceReferenceLabel("video", index)}</span>
|
||||
<ReferenceOrderButtons index={index} total={videoReferences.length} onMove={(offset) => setVideoReferences((value) => moveListItem(value, index, offset))} />
|
||||
<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={() => setVideoReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考视频">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">暂无参考视频,最多 3 个</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">参考音频</span>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</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">
|
||||
{audioReferences.map((item, index) => (
|
||||
<div key={item.id} className="group relative flex h-20 w-48 shrink-0 flex-col justify-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2 dark:border-stone-800 dark:bg-stone-900">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
<Music2 className="size-4 shrink-0" />
|
||||
<span className="shrink-0 rounded bg-stone-200 px-1 text-[10px] text-stone-700 dark:bg-stone-800 dark:text-stone-200">{seedanceReferenceLabel("audio", index)}</span>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
<audio src={item.url} controls className="h-8 w-full" preload="metadata" />
|
||||
<ReferenceOrderButtons index={index} total={audioReferences.length} onMove={(offset) => setAudioReferences((value) => moveListItem(value, index, offset))} />
|
||||
<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={() => setAudioReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考音频">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">暂无参考音频,最多 3 个,mp3/wav,单个 15MB 内</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">
|
||||
{modelOptionLabel(effectiveConfig, model)} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
|
||||
</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">
|
||||
<h2 className="text-xl font-semibold">生成结果</h2>
|
||||
{running ? <Tag className="m-0 px-2 py-1">等待 {formatDuration(elapsedMs)}</Tag> : null}
|
||||
</div>
|
||||
{results.length ? (
|
||||
<div className="grid gap-4">
|
||||
{results.map((result) => (result.status === "success" && result.video ? <ResultVideoCard key={result.id} video={result.video} onDownload={downloadVideo} onSaveAsset={saveResultToAssets} /> : result.status === "failed" ? <FailedVideoCard key={result.id} error={result.error || "生成失败"} onRetry={retryResult} /> : <PendingVideoCard 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]">
|
||||
<VideoIcon 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/*,video/mp4,video/quicktime,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
|
||||
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={previewGenerationLog} />
|
||||
</Drawer>
|
||||
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||
<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 }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
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("videoModel", value)} capability="video" fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<VideoSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
<video src={video.url} controls className="aspect-video w-full bg-black object-contain" />
|
||||
<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>
|
||||
{video.width}x{video.height}
|
||||
</span>
|
||||
<span>{formatBytes(video.bytes)}</span>
|
||||
<span>{formatDuration(video.durationMs)}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
|
||||
添加到素材
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingVideoCard() {
|
||||
return (
|
||||
<div className="relative aspect-video 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 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 FailedVideoCard({ 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-video 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 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">
|
||||
<h2 className="text-base font-semibold">生成记录</h2>
|
||||
<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-[auto_minmax(0,1fr)_auto] 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>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.size}</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.resolution}p</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.seconds}s</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid justify-items-end gap-2">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : log.status === "生成中" ? "processing" : "red"}>
|
||||
{log.status}
|
||||
</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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function readStoredLogs() {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const logs: GenerationLog[] = [];
|
||||
await logStore.iterate<GenerationLog, void>((value) => {
|
||||
logs.push(value);
|
||||
});
|
||||
return (await Promise.all(logs.map(normalizeLog))).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
|
||||
const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video;
|
||||
const videoReferences = await Promise.all(
|
||||
(log.videoReferences || []).map(async (item) => ({
|
||||
...item,
|
||||
url: item.storageKey ? await resolveMediaUrl(item.storageKey, item.url) : item.url,
|
||||
})),
|
||||
);
|
||||
const audioReferences = await Promise.all(
|
||||
(log.audioReferences || []).map(async (item) => ({
|
||||
...item,
|
||||
url: item.storageKey ? await resolveMediaUrl(item.storageKey, item.url) : item.url,
|
||||
})),
|
||||
);
|
||||
const references = await Promise.all(
|
||||
(log.references || []).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 || "",
|
||||
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model: log.model || config.videoModel || "",
|
||||
config,
|
||||
references,
|
||||
videoReferences,
|
||||
audioReferences,
|
||||
durationMs: log.durationMs || 0,
|
||||
size: log.size || config.size || "",
|
||||
resolution: normalizeResolution(log.resolution || config.vquality || ""),
|
||||
seconds: log.seconds || config.videoSeconds || "",
|
||||
status: log.status || "成功",
|
||||
task: log.task,
|
||||
video,
|
||||
error: log.error,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeLog(log: GenerationLog): GenerationLog {
|
||||
return {
|
||||
...log,
|
||||
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
|
||||
videoReferences: log.videoReferences.map((item) => (item.storageKey ? { ...item, url: "" } : item)),
|
||||
audioReferences: log.audioReferences.map((item) => (item.storageKey ? { ...item, url: "" } : item)),
|
||||
video: log.video?.storageKey ? { ...log.video, url: "" } : log.video,
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportedAudioFile(file: File) {
|
||||
return file.type === "audio/mpeg" || file.type === "audio/mp3" || file.type === "audio/wav" || file.type === "audio/x-wav" || /\.(mp3|wav)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function filterAudioReferencesByDuration(existing: ReferenceAudio[], next: ReferenceAudio[], warn: (content: string) => void) {
|
||||
let total = existing.reduce((sum, item) => sum + (item.durationMs || 0), 0);
|
||||
const accepted: ReferenceAudio[] = [];
|
||||
let skipped = false;
|
||||
for (const item of next) {
|
||||
if (item.durationMs && (item.durationMs < 2000 || item.durationMs > 15000)) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
if (item.durationMs && total + item.durationMs > 15000) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
total += item.durationMs || 0;
|
||||
accepted.push(item);
|
||||
}
|
||||
if (skipped) warn("已忽略不符合时长要求的参考音频:单个 2-15 秒,总时长不超过 15 秒");
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function moveListItem<T>(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 (
|
||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
|
||||
return {
|
||||
model: log.config?.model || log.model || "",
|
||||
videoModel: log.config?.videoModel || log.model || "",
|
||||
size: log.config?.size || log.size || "",
|
||||
vquality: normalizeResolution(log.config?.vquality || log.resolution || ""),
|
||||
videoSeconds: log.config?.videoSeconds || log.seconds || "",
|
||||
videoGenerateAudio: log.config?.videoGenerateAudio || "true",
|
||||
videoWatermark: log.config?.videoWatermark || "false",
|
||||
};
|
||||
}
|
||||
|
||||
function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, task, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; task?: VideoGenerationTask; video?: GeneratedVideo; error?: string }): GenerationLog {
|
||||
const logConfig = {
|
||||
model: config.model,
|
||||
videoModel: config.videoModel,
|
||||
size: config.size,
|
||||
vquality: normalizeResolution(config.vquality),
|
||||
videoSeconds: config.videoSeconds,
|
||||
videoGenerateAudio: config.videoGenerateAudio,
|
||||
videoWatermark: config.videoWatermark,
|
||||
};
|
||||
return {
|
||||
id: nanoid(),
|
||||
createdAt: Date.now(),
|
||||
title: prompt.slice(0, 12) || "未命名",
|
||||
prompt,
|
||||
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model,
|
||||
config: logConfig,
|
||||
references,
|
||||
videoReferences,
|
||||
audioReferences,
|
||||
durationMs,
|
||||
size: logConfig.size,
|
||||
resolution: logConfig.vquality,
|
||||
seconds: logConfig.videoSeconds,
|
||||
status,
|
||||
task,
|
||||
video,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVideoConfig(config: AiConfig, model: string): AiConfig {
|
||||
const seedance = isSeedanceVideoConfig({ ...config, model });
|
||||
return {
|
||||
...config,
|
||||
model,
|
||||
videoModel: model,
|
||||
size: seedance ? normalizeSeedanceRatio(config.size) : normalizeVideoSize(config.size),
|
||||
videoSeconds: normalizeVideoSeconds(config.videoSeconds),
|
||||
vquality: normalizeResolution(config.vquality),
|
||||
videoGenerateAudio: String(boolConfig(config.videoGenerateAudio, true)),
|
||||
videoWatermark: String(boolConfig(config.videoWatermark, false)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
if (String(value).trim() === "-1") return "-1";
|
||||
const seconds = Math.floor(Number(value) || 6);
|
||||
return String(Math.max(1, Math.min(20, seconds)));
|
||||
}
|
||||
|
||||
function normalizeVideoSize(value: string) {
|
||||
return normalizeVideoSizeValue(value);
|
||||
}
|
||||
|
||||
function normalizeResolution(value: string) {
|
||||
return normalizeVideoResolutionValue(value);
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user