增强画布生成与资产管理
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getGeneratedImagePathFromUrl,
|
||||
readPersistedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json().catch(() => ({}))) as {
|
||||
filePath?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
const imageUrl = payload.imageUrl?.trim();
|
||||
const filePath = resolveReadableImagePath(imageUrl, payload.filePath?.trim());
|
||||
|
||||
if (filePath) {
|
||||
const buffer = await readPersistedImageFile(filePath);
|
||||
if (!buffer) {
|
||||
return NextResponse.json({ error: "图片文件不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return createImageResponse(buffer, getImageContentType(filePath));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: "图片地址不可读取" }, { status: 400 });
|
||||
}
|
||||
|
||||
const remoteImage = await readRemoteImage(imageUrl);
|
||||
if (!remoteImage) {
|
||||
return NextResponse.json({ error: "远程图片不可访问" }, { status: 502 });
|
||||
}
|
||||
|
||||
return createImageResponse(remoteImage.buffer, remoteImage.contentType);
|
||||
}
|
||||
|
||||
function createImageResponse(buffer: Buffer, contentType: string) {
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveReadableImagePath(imageUrl?: string, filePath?: string) {
|
||||
if (filePath?.startsWith("s3://")) return filePath;
|
||||
if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readRemoteImage(imageUrl: string) {
|
||||
if (!isAllowedRemoteImageUrl(imageUrl)) return null;
|
||||
|
||||
const response = await fetch(imageUrl, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/png";
|
||||
if (!contentType.startsWith("image/")) return null;
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedRemoteImageUrl(imageUrl: string) {
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "file.hanhan.ltd" &&
|
||||
url.pathname.startsWith("/imagegen-tools/generated/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getImageContentType(filePath: string) {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase();
|
||||
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
|
||||
if (extension === "webp") return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json().catch(() => ({}))) as {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
const baseUrl = normalizeBaseUrl(payload.baseUrl);
|
||||
const apiKey = payload.apiKey?.trim();
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "请填写 Base URL 和 API Key" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
}).catch((error) => {
|
||||
throw Object.assign(new Error("模型列表拉取失败"), {
|
||||
cause: error,
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
const payloadText = await response.text();
|
||||
const body = parseJson(payloadText);
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
getStringField(body, "error") ||
|
||||
getNestedStringField(body, ["error", "message"]) ||
|
||||
`模型列表拉取失败:HTTP ${response.status}`,
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const models = extractModelIds(body);
|
||||
return NextResponse.json({ models });
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | undefined) {
|
||||
if (!value) return "";
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function extractModelIds(payload: unknown) {
|
||||
const candidate = payload as { data?: unknown };
|
||||
const list = Array.isArray(candidate.data)
|
||||
? candidate.data
|
||||
: Array.isArray(payload)
|
||||
? payload
|
||||
: [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
list
|
||||
.map((item) =>
|
||||
typeof item === "string"
|
||||
? item
|
||||
: typeof item === "object" && item !== null && "id" in item
|
||||
? String((item as { id: unknown }).id)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function getStringField(payload: unknown, key: string) {
|
||||
if (!payload || typeof payload !== "object" || !(key in payload)) return "";
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getNestedStringField(payload: unknown, keys: string[]) {
|
||||
let current = payload;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) return "";
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return typeof current === "string" ? current : "";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
defaultCanvasSettings,
|
||||
normalizeCanvasSettings,
|
||||
} from "@/lib/canvas/settings";
|
||||
import {
|
||||
getJsonSetting,
|
||||
setJsonSetting,
|
||||
} from "@/lib/server/repositories/app-settings-repository";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const canvasSettingsKey = "canvasSettings";
|
||||
|
||||
export async function GET() {
|
||||
const settings = getJsonSetting(canvasSettingsKey, defaultCanvasSettings);
|
||||
return NextResponse.json({ item: normalizeCanvasSettings(settings) });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const settings = normalizeCanvasSettings(payload);
|
||||
setJsonSetting(canvasSettingsKey, settings);
|
||||
return NextResponse.json({ item: settings });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
deleteHistoryItem,
|
||||
updateHistoryNote,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const { id } = await context.params;
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing history id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = deleteHistoryItem(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "素材不存在或已删除" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
const { id } = await context.params;
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing history id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const note = typeof payload.note === "string" ? payload.note.slice(0, 1000) : "";
|
||||
const updated = updateHistoryNote(id, note);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "素材不存在或已删除" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, note });
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listHistory } from "@/lib/server/repositories/history-repository";
|
||||
import {
|
||||
listHistory,
|
||||
type HistorySource,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ items: listHistory() });
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = normalizeSource(searchParams.get("source"));
|
||||
const limit = Number(searchParams.get("limit") ?? "100");
|
||||
return NextResponse.json({ items: listHistory({ source, limit }) });
|
||||
}
|
||||
|
||||
function normalizeSource(value: string | null): HistorySource | "all" | undefined {
|
||||
if (value === "direct" || value === "canvas" || value === "all") return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+6
-1
@@ -2,7 +2,12 @@ import { HomeShell, type ActiveView } from "@/components/direct/home-shell";
|
||||
|
||||
function normalizeView(value: string | string[] | undefined): ActiveView {
|
||||
const candidate = Array.isArray(value) ? value[0] : value;
|
||||
if (candidate === "history" || candidate === "settings" || candidate === "canvas") {
|
||||
if (
|
||||
candidate === "history" ||
|
||||
candidate === "assets" ||
|
||||
candidate === "settings" ||
|
||||
candidate === "canvas"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return "studio";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,21 @@ import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { FileText, ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
|
||||
import { Archive, FileText, ImageIcon, Search, Sparkles, Trash2, Upload, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview";
|
||||
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CanvasSettings } from "@/lib/canvas/settings";
|
||||
import type {
|
||||
CanvasConfigNodeMetadata,
|
||||
CanvasImageNodeMetadata,
|
||||
@@ -45,11 +57,13 @@ type CanvasNodeInspectorProps = {
|
||||
isHistoryLoading: boolean;
|
||||
job: ImageJobPayload | null;
|
||||
showLibrary: boolean;
|
||||
canvasSettings: CanvasSettings;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
onDeleteNode: (id: string) => void;
|
||||
onGenerateNode: (id: string) => void;
|
||||
onInsertHistory: (item: HistoryItem) => void;
|
||||
onDeleteHistory: (item: HistoryItem) => void;
|
||||
onUploadImage: (file: File) => void;
|
||||
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
||||
onCloseLibrary: () => void;
|
||||
@@ -61,21 +75,26 @@ export function CanvasNodeInspector({
|
||||
isHistoryLoading,
|
||||
job,
|
||||
showLibrary,
|
||||
canvasSettings,
|
||||
promptMentionSources,
|
||||
onPatchNode,
|
||||
onDeleteNode,
|
||||
onGenerateNode,
|
||||
onInsertHistory,
|
||||
onDeleteHistory,
|
||||
onUploadImage,
|
||||
onReplaceNodeImage,
|
||||
onCloseLibrary,
|
||||
}: CanvasNodeInspectorProps) {
|
||||
const [historyFilter, setHistoryFilter] = useState("");
|
||||
const [libraryKind, setLibraryKind] = useState<"all" | "text" | "image" | "video">("all");
|
||||
const filteredHistory = history.filter((item) =>
|
||||
`${item.prompt} ${item.model} ${item.size}`
|
||||
.toLowerCase()
|
||||
.includes(historyFilter.toLowerCase()),
|
||||
);
|
||||
const visibleHistory =
|
||||
libraryKind === "all" || libraryKind === "image" ? filteredHistory : [];
|
||||
|
||||
if (!node && !showLibrary && !job?.progressMessage) {
|
||||
return null;
|
||||
@@ -91,6 +110,7 @@ export function CanvasNodeInspector({
|
||||
<FloatingCard className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto pr-1">
|
||||
<SelectedNodeForm
|
||||
canvasSettings={canvasSettings}
|
||||
node={node}
|
||||
promptMentionSources={promptMentionSources}
|
||||
onDeleteNode={onDeleteNode}
|
||||
@@ -102,92 +122,27 @@ export function CanvasNodeInspector({
|
||||
</FloatingCard>
|
||||
) : null}
|
||||
|
||||
{showLibrary ? (
|
||||
<FloatingCard className="pointer-events-auto max-h-[calc(100vh-150px)] overflow-auto">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-950">素材库</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
从历史或本地图片插入参考节点
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onCloseLibrary}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="搜索提示词、模型或尺寸..."
|
||||
value={historyFilter}
|
||||
onChange={(event) => setHistoryFilter(event.target.value)}
|
||||
/>
|
||||
<label className="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-xl bg-zinc-950 px-3 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
<Upload className="size-4" />
|
||||
上传
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onUploadImage(file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2">
|
||||
{isHistoryLoading ? (
|
||||
<LibraryPlaceholder>正在加载历史...</LibraryPlaceholder>
|
||||
) : null}
|
||||
{!isHistoryLoading && filteredHistory.length === 0 ? (
|
||||
<LibraryPlaceholder>暂无可插入的历史图片。</LibraryPlaceholder>
|
||||
) : null}
|
||||
{filteredHistory.slice(0, 10).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-2.5 text-left transition hover:border-zinc-950 hover:shadow-[0_10px_24px_rgba(24,24,27,.08)]"
|
||||
onClick={() => onInsertHistory(item)}
|
||||
>
|
||||
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-zinc-900">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
alt={item.prompt}
|
||||
className="h-full w-full object-cover"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="size-5 text-white/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm leading-5 text-zinc-700">
|
||||
{item.prompt || "无提示词"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{item.model} · {item.size}
|
||||
</div>
|
||||
</div>
|
||||
<Plus className="size-4 shrink-0 text-zinc-500" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FloatingCard>
|
||||
) : null}
|
||||
</div>
|
||||
{showLibrary ? (
|
||||
<MaterialLibraryDialog
|
||||
filter={historyFilter}
|
||||
isLoading={isHistoryLoading}
|
||||
items={visibleHistory}
|
||||
kind={libraryKind}
|
||||
onClose={onCloseLibrary}
|
||||
onDelete={onDeleteHistory}
|
||||
onFilterChange={setHistoryFilter}
|
||||
onInsert={onInsertHistory}
|
||||
onKindChange={setLibraryKind}
|
||||
onUpload={onUploadImage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedNodeForm({
|
||||
canvasSettings,
|
||||
node,
|
||||
promptMentionSources,
|
||||
onPatchNode,
|
||||
@@ -195,6 +150,7 @@ function SelectedNodeForm({
|
||||
onGenerateNode,
|
||||
onReplaceNodeImage,
|
||||
}: {
|
||||
canvasSettings: CanvasSettings;
|
||||
node: CanvasNode;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
@@ -218,12 +174,12 @@ function SelectedNodeForm({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{node.type === "image" ? (
|
||||
{node.type === "image" || node.type === "prompt" ? (
|
||||
<Button
|
||||
className="rounded-full bg-zinc-950 text-white shadow-none hover:bg-zinc-800"
|
||||
size="icon"
|
||||
onClick={() => onGenerateNode(node.id)}
|
||||
title="从当前图像节点生成"
|
||||
title={node.type === "prompt" ? "从当前提示词生成" : "从当前图像节点生成"}
|
||||
>
|
||||
<Sparkles />
|
||||
</Button>
|
||||
@@ -253,11 +209,13 @@ function SelectedNodeForm({
|
||||
<PromptFields
|
||||
mentionSources={promptMentionSources}
|
||||
node={node}
|
||||
onGenerateNode={onGenerateNode}
|
||||
onPatchNode={onPatchNode}
|
||||
/>
|
||||
) : null}
|
||||
{node.type === "config" ? (
|
||||
<ConfigFields
|
||||
canvasSettings={canvasSettings}
|
||||
node={node}
|
||||
onGenerateNode={onGenerateNode}
|
||||
onPatchNode={onPatchNode}
|
||||
@@ -265,6 +223,7 @@ function SelectedNodeForm({
|
||||
) : null}
|
||||
{node.type === "image" ? (
|
||||
<ImageFields
|
||||
canvasSettings={canvasSettings}
|
||||
mentionSources={promptMentionSources}
|
||||
node={node}
|
||||
onPatchNode={onPatchNode}
|
||||
@@ -279,28 +238,266 @@ function SelectedNodeForm({
|
||||
function PromptFields({
|
||||
mentionSources,
|
||||
node,
|
||||
onGenerateNode,
|
||||
onPatchNode,
|
||||
}: {
|
||||
mentionSources: PromptMentionSource[];
|
||||
node: CanvasNode;
|
||||
onGenerateNode: (id: string) => void;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
}) {
|
||||
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||
function patchPrompt(nextPrompt: string) {
|
||||
function patch(next: Partial<CanvasPromptNodeMetadata>) {
|
||||
onPatchNode(node.id, {
|
||||
metadata: { ...metadata, prompt: nextPrompt },
|
||||
metadata: { ...metadata, ...next },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<MentionPromptEditor
|
||||
emptyState="当前提示词节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
|
||||
mentionHelp="只允许引用已连接到当前提示词节点的图片素材或文本。"
|
||||
mentionSources={mentionSources}
|
||||
placeholder={mentionSources.length ? "输入提示词,键入 @ 引用已连接素材" : "输入提示词"}
|
||||
value={metadata.prompt || ""}
|
||||
onChange={patchPrompt}
|
||||
/>
|
||||
<>
|
||||
<Field label="正向提示词">
|
||||
<MentionPromptEditor
|
||||
emptyState="当前提示词节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
|
||||
mentionHelp="只允许引用已连接到当前提示词节点的图片素材或文本。"
|
||||
mentionSources={mentionSources}
|
||||
placeholder={mentionSources.length ? "输入提示词,键入 @ 引用已连接素材" : "输入提示词"}
|
||||
value={metadata.prompt || ""}
|
||||
onChange={(prompt) => patch({ prompt })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between rounded-2xl bg-zinc-100 p-3">
|
||||
<div>
|
||||
<Label className="text-xs text-zinc-800">输入反向提示词</Label>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
开启后会作为排除内容随生成请求发送
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(metadata.enableNegativePrompt)}
|
||||
onCheckedChange={(enableNegativePrompt) =>
|
||||
patch({ enableNegativePrompt })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{metadata.enableNegativePrompt ? (
|
||||
<Field label="反向提示词">
|
||||
<MentionPromptEditor
|
||||
emptyState="反向提示词也可以引用已连接素材或文本,例如排除某个风格、元素或构图。"
|
||||
mentionHelp="反向提示词开启后才会参与生成;关闭开关会保留文本但不发送。"
|
||||
mentionSources={mentionSources}
|
||||
placeholder="例如:不要文字、水印、畸形手指、低清晰度"
|
||||
value={metadata.negativePrompt || ""}
|
||||
onChange={(negativePrompt) => patch({ negativePrompt })}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-12 w-full items-center justify-center rounded-2xl bg-zinc-950 text-sm font-medium text-white transition hover:bg-zinc-800"
|
||||
onClick={() => onGenerateNode(node.id)}
|
||||
>
|
||||
从提示词生成图片
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MaterialKind = "all" | "text" | "image" | "video";
|
||||
|
||||
const materialTabs: Array<{ id: MaterialKind; label: string }> = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "text", label: "文本" },
|
||||
{ id: "image", label: "图片" },
|
||||
{ id: "video", label: "视频" },
|
||||
];
|
||||
|
||||
function MaterialLibraryDialog({
|
||||
filter,
|
||||
isLoading,
|
||||
items,
|
||||
kind,
|
||||
onClose,
|
||||
onDelete,
|
||||
onFilterChange,
|
||||
onInsert,
|
||||
onKindChange,
|
||||
onUpload,
|
||||
}: {
|
||||
filter: string;
|
||||
isLoading: boolean;
|
||||
items: HistoryItem[];
|
||||
kind: MaterialKind;
|
||||
onClose: () => void;
|
||||
onDelete: (item: HistoryItem) => void;
|
||||
onFilterChange: (value: string) => void;
|
||||
onInsert: (item: HistoryItem) => void;
|
||||
onKindChange: (kind: MaterialKind) => void;
|
||||
onUpload: (file: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-0 z-[95] flex items-center justify-center bg-black/10 p-6 backdrop-blur-[1px]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(620px,calc(100vh-48px))] w-[min(860px,calc(100vw-48px))] flex-col rounded-xl bg-white shadow-[0_24px_80px_rgba(24,24,27,.18)] ring-1 ring-zinc-200"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-zinc-950">选择素材</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="shrink-0 px-12 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400" />
|
||||
<Input
|
||||
className="h-9 rounded border-zinc-300 bg-white pl-9"
|
||||
placeholder="搜索素材"
|
||||
value={filter}
|
||||
onChange={(event) => onFilterChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
{materialTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-sm transition",
|
||||
kind === tab.id
|
||||
? "bg-zinc-950 font-semibold text-white"
|
||||
: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950",
|
||||
)}
|
||||
onClick={() => onKindChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-400 hover:text-zinc-950">
|
||||
<Upload className="size-4" />
|
||||
上传图片
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onUpload(file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto px-12 py-5">
|
||||
{isLoading ? (
|
||||
<MaterialEmptyState>正在加载素材...</MaterialEmptyState>
|
||||
) : items.length ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(128px,1fr))] gap-4">
|
||||
{items.map((item) => (
|
||||
<MaterialImageCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDelete={onDelete}
|
||||
onInsert={onInsert}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<MaterialEmptyState>没有素材</MaterialEmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialImageCard({
|
||||
item,
|
||||
onDelete,
|
||||
onInsert,
|
||||
}: {
|
||||
item: HistoryItem;
|
||||
onDelete: (item: HistoryItem) => void;
|
||||
onInsert: (item: HistoryItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 text-left shadow-sm transition hover:border-zinc-950 hover:shadow-[0_14px_34px_rgba(24,24,27,.12)]"
|
||||
onClick={() => onInsert(item)}
|
||||
title="插入画布"
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-zinc-900">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
alt={item.prompt || "素材图片"}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ImageIcon className="size-7 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 bg-white p-2.5">
|
||||
<div className="line-clamp-1 text-xs font-medium text-zinc-900">
|
||||
{item.prompt || "无提示词"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-zinc-500">
|
||||
{item.model} · {item.size}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 flex size-7 items-center justify-center rounded-full bg-white/90 text-zinc-500 opacity-0 shadow-sm ring-1 ring-zinc-200 backdrop-blur transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title="删除素材"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除这个素材?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后它会从素材库移除。如果画布中仍在使用图片文件,文件会被保留。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onDelete(item)}>
|
||||
删除素材
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialEmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] flex-col items-center justify-center text-zinc-400">
|
||||
<Archive className="mb-3 size-12 stroke-[1.4]" />
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -740,10 +937,12 @@ function placeCaretAfter(node: Node) {
|
||||
}
|
||||
|
||||
function ConfigFields({
|
||||
canvasSettings,
|
||||
node,
|
||||
onGenerateNode,
|
||||
onPatchNode,
|
||||
}: {
|
||||
canvasSettings: CanvasSettings;
|
||||
node: CanvasNode;
|
||||
onGenerateNode: (id: string) => void;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
@@ -751,6 +950,7 @@ function ConfigFields({
|
||||
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
||||
const patch = (next: Partial<CanvasConfigNodeMetadata>) =>
|
||||
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
|
||||
const selectedChannelId = metadata.channelId || canvasSettings.selectedChannelId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -766,13 +966,12 @@ function ConfigFields({
|
||||
<InfoChip>参考视频 0 个</InfoChip>
|
||||
<InfoChip>参考音频 0 个</InfoChip>
|
||||
</div>
|
||||
<Field label="模型">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
value={metadata.model}
|
||||
onChange={(event) => patch({ model: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<ModelChannelFields
|
||||
channelId={selectedChannelId}
|
||||
model={metadata.model}
|
||||
settings={canvasSettings}
|
||||
onChange={(next) => patch(next)}
|
||||
/>
|
||||
<SizeSelector
|
||||
size={metadata.size}
|
||||
onSizeChange={(size) => patch({ size })}
|
||||
@@ -827,12 +1026,115 @@ function ConfigFields({
|
||||
);
|
||||
}
|
||||
|
||||
type ModelChannelPatch = {
|
||||
channelId?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
function ModelChannelFields({
|
||||
channelId,
|
||||
model,
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
channelId: string;
|
||||
model: string;
|
||||
settings: CanvasSettings;
|
||||
onChange: (next: ModelChannelPatch) => void;
|
||||
}) {
|
||||
const selectedChannel =
|
||||
settings.channels.find((channel) => channel.id === channelId) ??
|
||||
settings.channels[0];
|
||||
const channelModels = selectedChannel?.models ?? [];
|
||||
const resolvedModel =
|
||||
model ||
|
||||
settings.defaultModels.image ||
|
||||
settings.modelPreferences.image[0] ||
|
||||
channelModels[0] ||
|
||||
"";
|
||||
const modelOptions = resolvedModel && !channelModels.includes(resolvedModel)
|
||||
? [resolvedModel, ...channelModels]
|
||||
: channelModels;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-900">模型路由</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
生成时会随节点配置发送到后端
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
|
||||
节点级
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Field label="渠道配置">
|
||||
<Select
|
||||
value={selectedChannel?.id ?? ""}
|
||||
onValueChange={(nextChannelId) => {
|
||||
const nextChannel =
|
||||
settings.channels.find((channel) => channel.id === nextChannelId) ??
|
||||
settings.channels[0];
|
||||
const nextModels = nextChannel?.models ?? [];
|
||||
const nextModel = nextModels.includes(resolvedModel)
|
||||
? resolvedModel
|
||||
: nextModels[0] || resolvedModel;
|
||||
onChange({ channelId: nextChannelId, model: nextModel });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue placeholder="选择渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settings.channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型名称">
|
||||
{modelOptions.length ? (
|
||||
<Select
|
||||
value={resolvedModel}
|
||||
onValueChange={(nextModel) => onChange({ model: nextModel })}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((modelName) => (
|
||||
<SelectItem key={modelName} value={modelName}>
|
||||
{modelName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="先在渠道配置中添加模型"
|
||||
value={resolvedModel}
|
||||
onChange={(event) => onChange({ model: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageFields({
|
||||
canvasSettings,
|
||||
mentionSources,
|
||||
node,
|
||||
onPatchNode,
|
||||
onReplaceNodeImage,
|
||||
}: {
|
||||
canvasSettings: CanvasSettings;
|
||||
mentionSources: PromptMentionSource[];
|
||||
node: CanvasNode;
|
||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
@@ -841,6 +1143,7 @@ function ImageFields({
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
const patch = (next: Partial<CanvasImageNodeMetadata>) =>
|
||||
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
|
||||
const selectedChannelId = metadata.channelId || canvasSettings.selectedChannelId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -890,14 +1193,12 @@ function ImageFields({
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<Input
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="gpt-image-2"
|
||||
value={metadata.model || ""}
|
||||
onChange={(event) => patch({ model: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<ModelChannelFields
|
||||
channelId={selectedChannelId}
|
||||
model={metadata.model || ""}
|
||||
settings={canvasSettings}
|
||||
onChange={(next) => patch(next)}
|
||||
/>
|
||||
<Field label="模式">
|
||||
<Select
|
||||
value={metadata.mode}
|
||||
@@ -1079,11 +1380,3 @@ function MiniBadge({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPlaceholder({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-100 p-3 text-sm text-zinc-500">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Brush,
|
||||
Code2,
|
||||
Download,
|
||||
Eraser,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Info,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
|
||||
@@ -49,6 +52,7 @@ type CanvasNodeProps = {
|
||||
activeConnecting: boolean;
|
||||
connectionTarget: boolean;
|
||||
promptMentionSources?: PromptMentionSource[];
|
||||
showImageInfoBadge?: boolean;
|
||||
onSelect: (id: string, additive?: boolean) => void;
|
||||
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
|
||||
onConnectStart: (id: string) => void;
|
||||
@@ -56,9 +60,16 @@ type CanvasNodeProps = {
|
||||
onResize: (id: string, width: number, height: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onLocalEdit: (payload: LocalImageEditPayload) => void;
|
||||
onReplaceImage: (id: string, file: File) => void;
|
||||
};
|
||||
|
||||
export type LocalImageEditPayload = {
|
||||
nodeId: string;
|
||||
prompt: string;
|
||||
mask: File;
|
||||
};
|
||||
|
||||
export function CanvasNode({
|
||||
node,
|
||||
scale,
|
||||
@@ -69,6 +80,7 @@ export function CanvasNode({
|
||||
activeConnecting,
|
||||
connectionTarget,
|
||||
promptMentionSources = [],
|
||||
showImageInfoBadge = false,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
onConnectStart,
|
||||
@@ -76,6 +88,7 @@ export function CanvasNode({
|
||||
onResize,
|
||||
onDelete,
|
||||
onGenerate,
|
||||
onLocalEdit,
|
||||
onReplaceImage,
|
||||
}: CanvasNodeProps) {
|
||||
const [previewImage, setPreviewImage] = useState<{
|
||||
@@ -143,9 +156,9 @@ export function CanvasNode({
|
||||
imageReferenceLabel={imageReferenceLabel}
|
||||
node={node}
|
||||
onOpenImagePreview={(image) => setPreviewImage(image)}
|
||||
onGenerate={onGenerate}
|
||||
onSelect={onSelect}
|
||||
promptMentionSources={promptMentionSources}
|
||||
showImageInfoBadge={showImageInfoBadge}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +169,7 @@ export function CanvasNode({
|
||||
visible={selected}
|
||||
onDelete={onDelete}
|
||||
onGenerate={onGenerate}
|
||||
onLocalEdit={onLocalEdit}
|
||||
onOpenImagePreview={(image) => setPreviewImage(image)}
|
||||
onReplaceImage={onReplaceImage}
|
||||
onSelect={onSelect}
|
||||
@@ -280,16 +294,16 @@ function NodeBody({
|
||||
imageReferenceLabel,
|
||||
node,
|
||||
onOpenImagePreview,
|
||||
onGenerate,
|
||||
onSelect,
|
||||
promptMentionSources,
|
||||
showImageInfoBadge,
|
||||
}: {
|
||||
imageReferenceLabel?: string;
|
||||
node: CanvasNodeType;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
promptMentionSources: PromptMentionSource[];
|
||||
showImageInfoBadge: boolean;
|
||||
}) {
|
||||
if (node.type === "prompt") {
|
||||
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||
@@ -303,6 +317,11 @@ function NodeBody({
|
||||
prompt={metadata.prompt || ""}
|
||||
textClassName="text-zinc-700"
|
||||
/>
|
||||
{metadata.enableNegativePrompt && metadata.negativePrompt?.trim() ? (
|
||||
<div className="mt-3 rounded-2xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
|
||||
反向已启用:{metadata.negativePrompt.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -322,13 +341,14 @@ function NodeBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageNodeBody
|
||||
imageReferenceLabel={imageReferenceLabel}
|
||||
metadata={node.metadata as CanvasImageNodeMetadata}
|
||||
node={node}
|
||||
onOpenImagePreview={onOpenImagePreview}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<ImageNodeBody
|
||||
imageReferenceLabel={imageReferenceLabel}
|
||||
metadata={node.metadata as CanvasImageNodeMetadata}
|
||||
node={node}
|
||||
onOpenImagePreview={onOpenImagePreview}
|
||||
onSelect={onSelect}
|
||||
showImageInfoBadge={showImageInfoBadge}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,12 +358,14 @@ function ImageNodeBody({
|
||||
node,
|
||||
onOpenImagePreview,
|
||||
onSelect,
|
||||
showImageInfoBadge,
|
||||
}: {
|
||||
imageReferenceLabel?: string;
|
||||
metadata: CanvasImageNodeMetadata;
|
||||
node: CanvasNodeType;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onSelect: (id: string) => void;
|
||||
showImageInfoBadge: boolean;
|
||||
}) {
|
||||
if (metadata.status === "loading") {
|
||||
return (
|
||||
@@ -398,10 +420,49 @@ function ImageNodeBody({
|
||||
src={metadata.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
{showImageInfoBadge ? (
|
||||
<ImageInfoBadge metadata={metadata} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageInfoBadge({ metadata }: { metadata: CanvasImageNodeMetadata }) {
|
||||
const label = formatImageInfo(metadata);
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-10 rounded-full bg-zinc-900/72 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_10px_24px_rgba(24,24,27,.22)] ring-1 ring-white/20 backdrop-blur-md">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatImageInfo(metadata: CanvasImageNodeMetadata) {
|
||||
const width = metadata.naturalWidth;
|
||||
const height = metadata.naturalHeight;
|
||||
const dimensions =
|
||||
width && height ? `${Math.round(width)} x ${Math.round(height)}` : "";
|
||||
const fileSize = formatFileSize(metadata.fileSize);
|
||||
|
||||
return [dimensions, fileSize].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function formatFileSize(value: number | undefined) {
|
||||
if (!value || value <= 0) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let size = value / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function ImageNodeTitle({ node }: { node: CanvasNodeType }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -516,7 +577,7 @@ function ImageZoomDialog({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"thin-scrollbar flex min-h-0 flex-1 overflow-auto bg-white",
|
||||
"flex min-h-0 flex-1 overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dragState ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
@@ -583,6 +644,306 @@ function ImageZoomDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type LocalEditTool = "brush" | "erase";
|
||||
type LocalEditPoint = {
|
||||
displayX: number;
|
||||
displayY: number;
|
||||
maskX: number;
|
||||
maskY: number;
|
||||
};
|
||||
|
||||
function LocalImageEditDialog({
|
||||
image,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
image: { title: string; url: string };
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: { prompt: string; mask: File }) => void;
|
||||
}) {
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const maskRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [tool, setTool] = useState<LocalEditTool>("brush");
|
||||
const [brushSize, setBrushSize] = useState(100);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [hasMask, setHasMask] = useState(false);
|
||||
const lastPointRef = useRef<LocalEditPoint | null>(null);
|
||||
|
||||
const syncCanvases = () => {
|
||||
const imageElement = imageRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
const mask = maskRef.current;
|
||||
if (!imageElement || !overlay || !mask || !naturalSize.width || !naturalSize.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = imageElement.getBoundingClientRect();
|
||||
overlay.width = Math.max(1, Math.round(rect.width));
|
||||
overlay.height = Math.max(1, Math.round(rect.height));
|
||||
mask.width = naturalSize.width;
|
||||
mask.height = naturalSize.height;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
syncCanvases();
|
||||
window.addEventListener("resize", syncCanvases);
|
||||
return () => window.removeEventListener("resize", syncCanvases);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [naturalSize.width, naturalSize.height]);
|
||||
|
||||
const getCanvasPoint = (event: ReactMouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = overlayRef.current;
|
||||
if (!canvas || !naturalSize.width || !naturalSize.height) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
return {
|
||||
displayX: x,
|
||||
displayY: y,
|
||||
maskX: (x / Math.max(1, rect.width)) * naturalSize.width,
|
||||
maskY: (y / Math.max(1, rect.height)) * naturalSize.height,
|
||||
};
|
||||
};
|
||||
|
||||
const drawStroke = (from: LocalEditPoint, to: LocalEditPoint) => {
|
||||
const overlay = overlayRef.current;
|
||||
const mask = maskRef.current;
|
||||
if (!overlay || !mask || !naturalSize.width) return;
|
||||
const overlayContext = overlay.getContext("2d");
|
||||
const maskContext = mask.getContext("2d");
|
||||
if (!overlayContext || !maskContext) return;
|
||||
|
||||
const displayScale = overlay.width / naturalSize.width;
|
||||
const displayBrushSize = brushSize * displayScale;
|
||||
overlayContext.save();
|
||||
overlayContext.globalCompositeOperation = tool === "erase" ? "destination-out" : "source-over";
|
||||
overlayContext.strokeStyle = "rgba(92, 120, 255, 0.38)";
|
||||
overlayContext.lineWidth = displayBrushSize;
|
||||
overlayContext.lineCap = "round";
|
||||
overlayContext.lineJoin = "round";
|
||||
overlayContext.beginPath();
|
||||
overlayContext.moveTo(from.displayX, from.displayY);
|
||||
overlayContext.lineTo(to.displayX, to.displayY);
|
||||
overlayContext.stroke();
|
||||
overlayContext.restore();
|
||||
|
||||
maskContext.save();
|
||||
maskContext.globalCompositeOperation = tool === "erase" ? "destination-out" : "source-over";
|
||||
maskContext.strokeStyle = "rgba(255, 255, 255, 1)";
|
||||
maskContext.lineWidth = brushSize;
|
||||
maskContext.lineCap = "round";
|
||||
maskContext.lineJoin = "round";
|
||||
maskContext.beginPath();
|
||||
maskContext.moveTo(from.maskX, from.maskY);
|
||||
maskContext.lineTo(to.maskX, to.maskY);
|
||||
maskContext.stroke();
|
||||
maskContext.restore();
|
||||
setHasMask(true);
|
||||
};
|
||||
|
||||
const resetMask = () => {
|
||||
const overlay = overlayRef.current;
|
||||
const mask = maskRef.current;
|
||||
overlay?.getContext("2d")?.clearRect(0, 0, overlay.width, overlay.height);
|
||||
mask?.getContext("2d")?.clearRect(0, 0, mask.width, mask.height);
|
||||
setHasMask(false);
|
||||
};
|
||||
|
||||
const submitEdit = () => {
|
||||
if (!prompt.trim()) return;
|
||||
if (!hasMask) return;
|
||||
const mask = maskRef.current;
|
||||
if (!mask) return;
|
||||
const apiMask = document.createElement("canvas");
|
||||
apiMask.width = mask.width;
|
||||
apiMask.height = mask.height;
|
||||
const apiMaskContext = apiMask.getContext("2d");
|
||||
if (!apiMaskContext) return;
|
||||
apiMaskContext.fillStyle = "rgba(255, 255, 255, 1)";
|
||||
apiMaskContext.fillRect(0, 0, apiMask.width, apiMask.height);
|
||||
apiMaskContext.globalCompositeOperation = "destination-out";
|
||||
apiMaskContext.drawImage(mask, 0, 0);
|
||||
apiMask.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
onSubmit({
|
||||
prompt: prompt.trim(),
|
||||
mask: new File([blob], "local-edit-mask.png", { type: "image/png" }),
|
||||
});
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/42 p-4 backdrop-blur-[1px]"
|
||||
data-canvas-ui
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid h-[632px] max-h-[calc(100vh-32px)] w-[980px] max-w-[calc(100vw-32px)] grid-cols-[minmax(0,1fr)_320px] gap-5 overflow-hidden rounded-lg bg-white p-5 shadow-2xl ring-1 ring-zinc-200 max-lg:grid-cols-1"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-lg bg-zinc-100">
|
||||
<div className="relative inline-block max-h-full max-w-full">
|
||||
<img
|
||||
ref={imageRef}
|
||||
alt={image.title}
|
||||
className="block max-h-full max-w-full select-none object-contain"
|
||||
draggable={false}
|
||||
src={image.url}
|
||||
onLoad={(event) => {
|
||||
const element = event.currentTarget;
|
||||
setNaturalSize({
|
||||
width: element.naturalWidth || 1,
|
||||
height: element.naturalHeight || 1,
|
||||
});
|
||||
requestAnimationFrame(syncCanvases);
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={overlayRef}
|
||||
className="absolute inset-0 touch-none"
|
||||
onMouseDown={(event) => {
|
||||
const point = getCanvasPoint(event);
|
||||
if (!point) return;
|
||||
setIsDrawing(true);
|
||||
lastPointRef.current = point;
|
||||
drawStroke(point, point);
|
||||
}}
|
||||
onMouseMove={(event) => {
|
||||
if (!isDrawing) return;
|
||||
const point = getCanvasPoint(event);
|
||||
const lastPoint = lastPointRef.current;
|
||||
if (!point || !lastPoint) return;
|
||||
drawStroke(lastPoint, point);
|
||||
lastPointRef.current = point;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsDrawing(false);
|
||||
lastPointRef.current = null;
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setIsDrawing(false);
|
||||
lastPointRef.current = null;
|
||||
}}
|
||||
/>
|
||||
<canvas ref={maskRef} className="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-950">局部遮罩编辑</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
{naturalSize.width && naturalSize.height
|
||||
? `${naturalSize.width} x ${naturalSize.height}px`
|
||||
: "读取图片尺寸中"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center gap-2 rounded-md border text-sm font-medium transition",
|
||||
tool === "brush"
|
||||
? "border-zinc-950 bg-zinc-950 text-white"
|
||||
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50",
|
||||
)}
|
||||
onClick={() => setTool("brush")}
|
||||
>
|
||||
<Brush className="size-4" />
|
||||
画笔
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center gap-2 rounded-md border text-sm font-medium transition",
|
||||
tool === "erase"
|
||||
? "border-zinc-950 bg-zinc-950 text-white"
|
||||
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50",
|
||||
)}
|
||||
onClick={() => setTool("erase")}
|
||||
>
|
||||
<Eraser className="size-4" />
|
||||
擦除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mt-5 block text-sm font-medium text-zinc-700">
|
||||
<span className="flex items-center justify-between">
|
||||
<span>笔刷大小</span>
|
||||
<span>{brushSize}px</span>
|
||||
</span>
|
||||
<input
|
||||
className="mt-3 w-full accent-zinc-950"
|
||||
max={220}
|
||||
min={12}
|
||||
onChange={(event) => setBrushSize(Number(event.target.value))}
|
||||
type="range"
|
||||
value={brushSize}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-6 flex min-h-0 flex-1 flex-col text-sm font-medium text-zinc-700">
|
||||
修改要求
|
||||
<textarea
|
||||
className="mt-2 min-h-[140px] flex-1 resize-none rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal leading-6 text-zinc-950 outline-none transition focus:border-zinc-400"
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
value={prompt}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center justify-center rounded-md border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50"
|
||||
onClick={resetMask}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center justify-center rounded-md border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center justify-center gap-2 rounded-md bg-zinc-950 px-4 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!prompt.trim() || !hasMask}
|
||||
onClick={submitEdit}
|
||||
>
|
||||
<WandSparkles className="size-4" />
|
||||
AI 修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function NodeIcon({ type }: { type: CanvasNodeType["type"] }) {
|
||||
const className = "size-4";
|
||||
if (type === "prompt") return <FileText className={className} />;
|
||||
@@ -596,6 +957,7 @@ function ImageNodeToolbar({
|
||||
visible,
|
||||
onDelete,
|
||||
onGenerate,
|
||||
onLocalEdit,
|
||||
onOpenImagePreview,
|
||||
onReplaceImage,
|
||||
onSelect,
|
||||
@@ -605,11 +967,13 @@ function ImageNodeToolbar({
|
||||
visible: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onGenerate: (id: string) => void;
|
||||
onLocalEdit: (payload: LocalImageEditPayload) => void;
|
||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||
onReplaceImage: (id: string, file: File) => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const replaceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [showLocalEdit, setShowLocalEdit] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -656,6 +1020,14 @@ function ImageNodeToolbar({
|
||||
label="替换图片"
|
||||
onClick={() => replaceInputRef.current?.click()}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
icon={<Brush className="size-4" />}
|
||||
label="局部编辑"
|
||||
onClick={() => {
|
||||
onSelect(node.id);
|
||||
setShowLocalEdit(true);
|
||||
}}
|
||||
/>
|
||||
<ImageToolbarButton
|
||||
icon={<Sparkles className="size-4" />}
|
||||
label="生成"
|
||||
@@ -665,6 +1037,16 @@ function ImageNodeToolbar({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showLocalEdit ? (
|
||||
<LocalImageEditDialog
|
||||
image={{ title: node.title, url: imageUrl }}
|
||||
onClose={() => setShowLocalEdit(false)}
|
||||
onSubmit={(payload) => {
|
||||
setShowLocalEdit(false);
|
||||
onLocalEdit({ ...payload, nodeId: node.id });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasProjectListItem } from "@/lib/canvas/types";
|
||||
|
||||
@@ -105,9 +116,6 @@ export function CanvasProjectsClient() {
|
||||
}
|
||||
|
||||
async function deleteProject(project: CanvasProjectListItem) {
|
||||
const confirmed = window.confirm(`删除画布「${project.title}」?此操作不可撤销。`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingProjectId(project.id);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${project.id}`, {
|
||||
@@ -187,15 +195,32 @@ export function CanvasProjectsClient() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={deletingProjectId === project.id}
|
||||
onClick={() => void deleteProject(project)}
|
||||
title="删除画布"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={deletingProjectId === project.id}
|
||||
title="删除画布"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除画布「{project.title}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此操作不可撤销,画布项目和其中的节点数据都会被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void deleteProject(project)}>
|
||||
删除画布
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,379 +1,516 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
Grid2X2,
|
||||
Hand,
|
||||
ImageIcon,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
Menu,
|
||||
Minus,
|
||||
Plus,
|
||||
Redo2,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SquareDashedMousePointer,
|
||||
Trash2,
|
||||
Type,
|
||||
Undo2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
Grid2X2,
|
||||
Hand,
|
||||
Home,
|
||||
ImageIcon,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
Menu,
|
||||
Minus,
|
||||
Plus,
|
||||
Redo2,
|
||||
BookOpenText,
|
||||
Save,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SquareDashedMousePointer,
|
||||
LayoutGrid,
|
||||
Trash2,
|
||||
Type,
|
||||
Undo2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasBackgroundMode, CanvasNodeType } from "@/lib/canvas/types";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import type {CanvasBackgroundMode, CanvasNodeType} from "@/lib/canvas/types";
|
||||
|
||||
type CanvasToolbarProps = {
|
||||
title: string;
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
scale: number;
|
||||
showLibrary: boolean;
|
||||
selectionMode: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
canDeleteSelection: boolean;
|
||||
hideDockLabels: boolean;
|
||||
onBack: () => void;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onGenerate: () => void;
|
||||
onUploadMaterial: () => void;
|
||||
onCopyJson: () => void;
|
||||
onImportJson: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
onToggleLibrary: () => void;
|
||||
onToggleSelectionMode: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onOpenSettings: () => void;
|
||||
title: string;
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
lastSavedAt: Date | null;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
scale: number;
|
||||
showLibrary: boolean;
|
||||
selectionMode: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
canDeleteSelection: boolean;
|
||||
hideDockLabels: boolean;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onGenerate: () => void;
|
||||
onSave: () => void;
|
||||
onUploadMaterial: () => void;
|
||||
onCopyJson: () => void;
|
||||
onImportJson: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onClearCanvas: () => void;
|
||||
onGoHome: () => void;
|
||||
onGoCanvasList: () => void;
|
||||
onCreateCanvas: () => void;
|
||||
onDeleteCurrentCanvas: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
onToggleLibrary: () => void;
|
||||
onToggleSelectionMode: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export function CanvasToolbar({
|
||||
title,
|
||||
isSaving,
|
||||
isGenerating,
|
||||
backgroundMode,
|
||||
scale,
|
||||
showLibrary,
|
||||
selectionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDeleteSelection,
|
||||
hideDockLabels,
|
||||
onBack,
|
||||
onAddNode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onGenerate,
|
||||
onUploadMaterial,
|
||||
onCopyJson,
|
||||
onImportJson,
|
||||
onDeleteSelection,
|
||||
onClearCanvas,
|
||||
onBackgroundModeChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
onToggleLibrary,
|
||||
onToggleSelectionMode,
|
||||
onShowShortcuts,
|
||||
onOpenSettings,
|
||||
}: CanvasToolbarProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-4"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-2.5 rounded-[13px] bg-white/92 px-3 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onBack}
|
||||
title="返回画布列表"
|
||||
>
|
||||
<Menu className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-zinc-950">{title}</div>
|
||||
<div className="mt-0.5 text-[10px] tracking-[0.16em] text-zinc-400 uppercase">
|
||||
无限画布
|
||||
title,
|
||||
isSaving,
|
||||
isGenerating,
|
||||
lastSavedAt,
|
||||
backgroundMode,
|
||||
scale,
|
||||
showLibrary,
|
||||
selectionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDeleteSelection,
|
||||
hideDockLabels,
|
||||
onAddNode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onGenerate,
|
||||
onSave,
|
||||
onUploadMaterial,
|
||||
onCopyJson,
|
||||
onImportJson,
|
||||
onDeleteSelection,
|
||||
onClearCanvas,
|
||||
onGoHome,
|
||||
onGoCanvasList,
|
||||
onCreateCanvas,
|
||||
onDeleteCurrentCanvas,
|
||||
onBackgroundModeChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
onToggleLibrary,
|
||||
onToggleSelectionMode,
|
||||
onShowShortcuts,
|
||||
onOpenSettings,
|
||||
}: CanvasToolbarProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-4"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div ref={menuRef}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-3 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-md text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
title="打开菜单"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Menu className="size-5"/>
|
||||
</button>
|
||||
<div className="truncate text-[15px] font-semibold text-zinc-950">{title}</div>
|
||||
</div>
|
||||
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute left-0 top-[52px] w-[244px] overflow-hidden rounded-[14px] bg-white p-2 shadow-[0_24px_60px_rgba(24,24,27,.14)] ring-1 ring-black/5">
|
||||
<MenuItem icon={<Home className="size-4"/>} label="主页" onClick={onGoHome}/>
|
||||
<MenuItem icon={<BookOpenText className="size-4"/>} label="文档" disabled/>
|
||||
<MenuItem icon={<LayoutGrid className="size-4"/>} label="我的画布"
|
||||
onClick={onGoCanvasList}/>
|
||||
<MenuDivider/>
|
||||
<MenuItem icon={<Plus className="size-4"/>} label="新建画布" onClick={onCreateCanvas}/>
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 className="size-4"/>}
|
||||
label="删除当前画布"
|
||||
onClick={onDeleteCurrentCanvas}
|
||||
/>
|
||||
<MenuDivider/>
|
||||
<MenuItem icon={<Upload className="size-4"/>} label="导入素材" onClick={onUploadMaterial}/>
|
||||
<MenuDivider/>
|
||||
<MenuItem
|
||||
icon={<Undo2 className="size-4"/>}
|
||||
label="撤销"
|
||||
onClick={onUndo}
|
||||
shortcut="⌘ Z"
|
||||
disabled={!canUndo}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Redo2 className="size-4"/>}
|
||||
label="重做"
|
||||
onClick={onRedo}
|
||||
shortcut="⌘ ⇧ Z / ⌘ Y"
|
||||
disabled={!canRedo}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<SaveStatusButton
|
||||
isGenerating={isGenerating}
|
||||
isSaving={isSaving}
|
||||
lastSavedAt={lastSavedAt}
|
||||
onSave={onSave}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onOpenSettings}
|
||||
title="设置"
|
||||
>
|
||||
<Settings className="size-4"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onShowShortcuts}
|
||||
title="快捷键"
|
||||
>
|
||||
<Keyboard className="size-4"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-8 items-center justify-center rounded-full transition ${
|
||||
backgroundMode === "lines"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("lines")}
|
||||
title="线框背景"
|
||||
>
|
||||
<Grid2X2 className="size-4"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-8 items-center justify-center rounded-full transition ${
|
||||
backgroundMode === "dots"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("dots")}
|
||||
title="点阵背景"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4"/>
|
||||
</button>
|
||||
<Button
|
||||
className="h-8 rounded-full bg-zinc-950 px-3 text-xs shadow-none hover:bg-zinc-800"
|
||||
disabled={isGenerating}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin"/> : <WandSparkles/>}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<StatusPill isGenerating={isGenerating} isSaving={isSaving} />
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onOpenSettings}
|
||||
title="设置"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onShowShortcuts}
|
||||
title="快捷键"
|
||||
>
|
||||
<Keyboard className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-8 items-center justify-center rounded-full transition ${
|
||||
backgroundMode === "lines"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("lines")}
|
||||
title="线框背景"
|
||||
>
|
||||
<Grid2X2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-8 items-center justify-center rounded-full transition ${
|
||||
backgroundMode === "dots"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => onBackgroundModeChange("dots")}
|
||||
title="点阵背景"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
</button>
|
||||
<Button
|
||||
className="h-8 rounded-full bg-zinc-950 px-3 text-xs shadow-none hover:bg-zinc-800"
|
||||
disabled={isGenerating}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" /> : <WandSparkles />}
|
||||
生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 left-4 z-[90]"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onResetView}
|
||||
title="重置视图"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4"/>
|
||||
</button>
|
||||
<div className="min-w-12 text-center text-xs font-medium text-zinc-700">
|
||||
{Math.round(scale * 100)}%
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<Plus className="size-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 left-4 z-[90]"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onResetView}
|
||||
title="重置视图"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-12 text-center text-xs font-medium text-zinc-700">
|
||||
{Math.round(scale * 100)}%
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 left-1/2 z-[90] -translate-x-1/2"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<DockButton
|
||||
active={selectionMode}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={
|
||||
selectionMode ? (
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
) : (
|
||||
<Hand className="size-4" />
|
||||
)
|
||||
}
|
||||
label={selectionMode ? "框选" : "拖拽"}
|
||||
onClick={onToggleSelectionMode}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canUndo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Undo2 className="size-4" />}
|
||||
label="撤回"
|
||||
onClick={onUndo}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canRedo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Redo2 className="size-4" />}
|
||||
label="重做"
|
||||
onClick={onRedo}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Type className="size-4" />}
|
||||
label="文本"
|
||||
onClick={() => onAddNode("prompt")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
label="图片"
|
||||
onClick={() => onAddNode("image")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
label="配置"
|
||||
onClick={() => onAddNode("config")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Upload className="size-4" />}
|
||||
label="上传"
|
||||
onClick={onUploadMaterial}
|
||||
/>
|
||||
<DockButton
|
||||
active={showLibrary}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<FolderOpen className="size-4" />}
|
||||
label="素材库"
|
||||
onClick={onToggleLibrary}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Copy className="size-4" />}
|
||||
label="复制JSON"
|
||||
onClick={onCopyJson}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ClipboardPaste className="size-4" />}
|
||||
label="导入JSON"
|
||||
onClick={onImportJson}
|
||||
/>
|
||||
{canDeleteSelection ? (
|
||||
<>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="删除"
|
||||
onClick={onDeleteSelection}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Eraser className="size-4" />}
|
||||
label="清空"
|
||||
onClick={onClearCanvas}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 left-1/2 z-[90] -translate-x-1/2"
|
||||
data-canvas-ui
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||
<DockButton
|
||||
active={selectionMode}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={
|
||||
selectionMode ? (
|
||||
<SquareDashedMousePointer className="size-4"/>
|
||||
) : (
|
||||
<Hand className="size-4"/>
|
||||
)
|
||||
}
|
||||
label={selectionMode ? "框选" : "拖拽"}
|
||||
onClick={onToggleSelectionMode}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canUndo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Undo2 className="size-4"/>}
|
||||
label="撤回"
|
||||
onClick={onUndo}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canRedo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Redo2 className="size-4"/>}
|
||||
label="重做"
|
||||
onClick={onRedo}
|
||||
/>
|
||||
<DockDivider/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Type className="size-4"/>}
|
||||
label="文本"
|
||||
onClick={() => onAddNode("prompt")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ImageIcon className="size-4"/>}
|
||||
label="图片"
|
||||
onClick={() => onAddNode("image")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Sparkles className="size-4"/>}
|
||||
label="配置"
|
||||
onClick={() => onAddNode("config")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Upload className="size-4"/>}
|
||||
label="上传"
|
||||
onClick={onUploadMaterial}
|
||||
/>
|
||||
<DockButton
|
||||
active={showLibrary}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<FolderOpen className="size-4"/>}
|
||||
label="素材库"
|
||||
onClick={onToggleLibrary}
|
||||
/>
|
||||
<DockDivider/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Copy className="size-4"/>}
|
||||
label="复制JSON"
|
||||
onClick={onCopyJson}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ClipboardPaste className="size-4"/>}
|
||||
label="导入JSON"
|
||||
onClick={onImportJson}
|
||||
/>
|
||||
{canDeleteSelection ? (
|
||||
<>
|
||||
<DockDivider/>
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Trash2 className="size-4"/>}
|
||||
label="删除"
|
||||
onClick={onDeleteSelection}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<DockDivider/>
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Eraser className="size-4"/>}
|
||||
label="清空"
|
||||
onClick={onClearCanvas}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
isSaving,
|
||||
isGenerating,
|
||||
}: {
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
shortcut,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "已同步";
|
||||
const tone = isGenerating
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: isSaving
|
||||
? "bg-sky-100 text-sky-800"
|
||||
: "bg-emerald-100 text-emerald-800";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`flex w-full items-center gap-3 rounded-[11px] px-3 py-2.5 text-left text-[14px] transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
danger
|
||||
? "text-red-500 hover:bg-red-50"
|
||||
: "text-zinc-800 hover:bg-zinc-100 hover:text-zinc-950"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`flex size-5 items-center justify-center ${danger ? "text-red-500" : "text-zinc-700"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span className="truncate">{label}</span>
|
||||
{shortcut ? <span className="shrink-0 text-[12px] text-zinc-400">{shortcut}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${tone}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
function MenuDivider() {
|
||||
return <div className="my-2 h-px bg-zinc-200"/>;
|
||||
}
|
||||
|
||||
function SaveStatusButton({
|
||||
isSaving,
|
||||
isGenerating,
|
||||
lastSavedAt,
|
||||
onSave,
|
||||
}: {
|
||||
isSaving: boolean;
|
||||
isGenerating: boolean;
|
||||
lastSavedAt: Date | null;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "保存";
|
||||
const savedTime = lastSavedAt
|
||||
? lastSavedAt.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "--:--";
|
||||
const title = isSaving ? "正在保存画布" : `保存画布,上次保存 ${savedTime}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1.5 rounded-full px-2 text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950 disabled:cursor-wait disabled:opacity-70"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
title={title}
|
||||
>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin"/> : <Save className="size-4"/>}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-[11px] font-medium">{label}</span>
|
||||
<span className="mt-0.5 text-[9px] font-medium text-zinc-400">{savedTime}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DockButton({
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
hideLabel = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
hideLabel?: boolean;
|
||||
onClick: () => void;
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
hideLabel = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
hideLabel?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`group relative flex h-9 items-center justify-center gap-2 rounded-[10px] text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40 ${hideLabel ? "w-9 px-0" : "px-2.5 max-[1100px]:w-9 max-[1100px]:px-0"} ${
|
||||
danger
|
||||
? active
|
||||
? "bg-red-50 text-red-600"
|
||||
: "text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
: active
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950"
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
<span className={hideLabel ? "sr-only" : "hidden min-[1101px]:inline"}>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`group relative flex h-9 items-center justify-center gap-2 rounded-[10px] text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40 ${hideLabel ? "w-9 px-0" : "px-2.5 max-[1100px]:w-9 max-[1100px]:px-0"} ${
|
||||
danger
|
||||
? active
|
||||
? "bg-red-50 text-red-600"
|
||||
: "text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
: active
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950"
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
<span className={hideLabel ? "sr-only" : "hidden min-[1101px]:inline"}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-lg transition group-hover:opacity-100 group-focus-visible:opacity-100 ${hideLabel ? "" : "min-[1101px]:hidden"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-lg transition group-hover:opacity-100 group-focus-visible:opacity-100 ${hideLabel ? "" : "min-[1101px]:hidden"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DockDivider() {
|
||||
return <div className="mx-1 h-6 w-px bg-zinc-200" />;
|
||||
return <div className="mx-1 h-6 w-px bg-zinc-200"/>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Download,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { HistoryItem } from "@/lib/image-workflow";
|
||||
|
||||
type AssetFilter = "all" | "direct" | "canvas" | "generate" | "edit";
|
||||
|
||||
const filters: Array<{ id: AssetFilter; label: string }> = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "direct", label: "直接模式" },
|
||||
{ id: "canvas", label: "无限画布" },
|
||||
{ id: "generate", label: "文生图" },
|
||||
{ id: "edit", label: "图像编辑" },
|
||||
];
|
||||
|
||||
export function AssetManager() {
|
||||
const [items, setItems] = useState<HistoryItem[]>([]);
|
||||
const [filter, setFilter] = useState<AssetFilter>("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingIds, setSavingIds] = useState<string[]>([]);
|
||||
const [deletingIds, setDeletingIds] = useState<string[]>([]);
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<HistoryItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAssets();
|
||||
}, []);
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() =>
|
||||
items.filter((item) => {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "direct" || filter === "canvas") {
|
||||
return (item.source ?? "direct") === filter;
|
||||
}
|
||||
return item.mode === filter;
|
||||
}),
|
||||
[filter, items],
|
||||
);
|
||||
const selectedItem =
|
||||
items.find((item) => item.id === selectedItemId) ?? null;
|
||||
|
||||
async function loadAssets() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/history?source=all&limit=500", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "读取资产失败");
|
||||
}
|
||||
const nextItems = Array.isArray(payload.items) ? payload.items : [];
|
||||
setItems(nextItems);
|
||||
setDraftNotes(
|
||||
Object.fromEntries(
|
||||
nextItems.map((item: HistoryItem) => [item.id, item.note ?? ""]),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "读取资产失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote(item: HistoryItem) {
|
||||
setSavingIds((current) => [...current, item.id]);
|
||||
try {
|
||||
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: draftNotes[item.id] ?? "" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "保存备注失败");
|
||||
}
|
||||
setItems((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === item.id ? { ...entry, note: payload.note ?? "" } : entry,
|
||||
),
|
||||
);
|
||||
toast.success("备注已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存备注失败");
|
||||
} finally {
|
||||
setSavingIds((current) => current.filter((id) => id !== item.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(item: HistoryItem) {
|
||||
setDeletingIds((current) => [...current, item.id]);
|
||||
try {
|
||||
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "删除素材失败");
|
||||
}
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
setSelectedItemId((current) => (current === item.id ? null : current));
|
||||
toast.success("素材已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除素材失败");
|
||||
} finally {
|
||||
setDeletingIds((current) => current.filter((id) => id !== item.id));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
selectedItem
|
||||
? "grid h-full min-h-0 overflow-hidden lg:grid-cols-[minmax(0,1fr)_360px]"
|
||||
: "grid h-full min-h-0 overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className="min-h-0 overflow-auto p-4 lg:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={filter === item.id ? "default" : "outline"}
|
||||
onClick={() => setFilter(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadAssets()}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <ImageIcon />}
|
||||
刷新资产
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
|
||||
正在加载资产...
|
||||
</div>
|
||||
) : visibleItems.length ? (
|
||||
<div className="columns-1 gap-4 sm:columns-2 xl:columns-3 2xl:columns-4">
|
||||
{visibleItems.map((item) => {
|
||||
const isDeleting = deletingIds.includes(item.id);
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="group mb-4 break-inside-avoid overflow-visible rounded-md border border-zinc-200 bg-white shadow-xs"
|
||||
>
|
||||
<div className="relative bg-zinc-100">
|
||||
<AssetToolbar
|
||||
item={item}
|
||||
onDelete={() => void deleteAsset(item)}
|
||||
onEdit={() => setSelectedItemId(item.id)}
|
||||
onInfo={() => setSelectedItemId(item.id)}
|
||||
/>
|
||||
<div className="overflow-hidden rounded-t-md">
|
||||
<img
|
||||
alt={item.prompt || "生成图片"}
|
||||
className="w-full cursor-zoom-in object-cover transition-transform duration-300 ease-out group-hover:scale-[1.035]"
|
||||
src={item.imageUrl}
|
||||
onClick={() => setPreviewItem(item)}
|
||||
/>
|
||||
</div>
|
||||
{isDeleting ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-white/70">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-zinc-950/45 via-zinc-950/18 to-transparent px-3 pb-3 pt-14 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div className="line-clamp-3 text-sm font-medium leading-5 drop-shadow">
|
||||
{item.prompt || "无提示词"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
|
||||
当前分类没有资产
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedItem ? (
|
||||
<AssetInfoPanel
|
||||
draftNote={draftNotes[selectedItem.id] ?? ""}
|
||||
item={selectedItem}
|
||||
isSaving={savingIds.includes(selectedItem.id)}
|
||||
onClose={() => setSelectedItemId(null)}
|
||||
onDelete={(item) => void deleteAsset(item)}
|
||||
onNoteChange={(item, note) =>
|
||||
setDraftNotes((current) => ({
|
||||
...current,
|
||||
[item.id]: note,
|
||||
}))
|
||||
}
|
||||
onSaveNote={(item) => void saveNote(item)}
|
||||
/>
|
||||
) : null}
|
||||
{previewItem ? (
|
||||
<ImageZoomDialog
|
||||
image={{
|
||||
title: previewItem.prompt || "生成图片",
|
||||
url: previewItem.imageUrl,
|
||||
}}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetToolbar({
|
||||
item,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onInfo,
|
||||
}: {
|
||||
item: HistoryItem;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onInfo: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+10px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 opacity-0 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity group-hover:opacity-100">
|
||||
<AssetToolbarButton
|
||||
icon={<Info className="size-4" />}
|
||||
label="信息"
|
||||
onClick={onInfo}
|
||||
/>
|
||||
<AssetToolbarButton
|
||||
danger
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="删除"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
<AssetToolbarButton
|
||||
icon={<Pencil className="size-4" />}
|
||||
label="编辑"
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<AssetToolbarButton
|
||||
icon={<Download className="size-4" />}
|
||||
label="下载"
|
||||
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetToolbarButton({
|
||||
danger = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
danger?: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
danger
|
||||
? "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-red-50 hover:text-red-600"
|
||||
: "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetInfoPanel({
|
||||
draftNote,
|
||||
isSaving,
|
||||
item,
|
||||
onClose,
|
||||
onDelete,
|
||||
onNoteChange,
|
||||
onSaveNote,
|
||||
}: {
|
||||
draftNote: string;
|
||||
isSaving: boolean;
|
||||
item: HistoryItem | null;
|
||||
onClose: () => void;
|
||||
onDelete: (item: HistoryItem) => void;
|
||||
onNoteChange: (item: HistoryItem, note: string) => void;
|
||||
onSaveNote: (item: HistoryItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 border-l border-zinc-200 bg-white lg:block">
|
||||
{item ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-950">图片信息</div>
|
||||
<div className="text-xs text-zinc-500">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
<Button size="icon" type="button" variant="ghost" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto p-4">
|
||||
<div className="overflow-hidden rounded-md bg-zinc-100">
|
||||
<img
|
||||
alt={item.prompt || "生成图片"}
|
||||
className="max-h-[520px] w-full object-contain"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge>{(item.source ?? "direct") === "canvas" ? "无限画布" : "直接模式"}</Badge>
|
||||
<Badge>{item.mode === "edit" ? "图像编辑" : "文生图"}</Badge>
|
||||
<Badge>{item.outputFormat.toUpperCase()}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<InfoBlock label="提示词">
|
||||
<p className="text-sm leading-6 text-zinc-950">
|
||||
{item.prompt || "无提示词"}
|
||||
</p>
|
||||
</InfoBlock>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<InfoCell label="模型" value={item.model} />
|
||||
<InfoCell label="尺寸" value={item.size} />
|
||||
<InfoCell label="质量" value={item.quality} />
|
||||
<InfoCell label="格式" value={item.outputFormat} />
|
||||
</div>
|
||||
<InfoBlock label="备注">
|
||||
<Textarea
|
||||
className="min-h-28"
|
||||
placeholder="添加备注"
|
||||
value={draftNote}
|
||||
onChange={(event) => onNoteChange(item, event.target.value)}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSaveNote(item)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
保存备注
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(item)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</InfoBlock>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
|
||||
>
|
||||
<Download />
|
||||
下载图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageZoomDialog({
|
||||
image,
|
||||
onClose,
|
||||
}: {
|
||||
image: { title: string; url: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [naturalSize, setNaturalSize] = useState<{
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [dragState, setDragState] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
} | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const zoomIn = () => setZoom((current) => current * 1.25);
|
||||
const zoomOut = () => setZoom((current) => Math.max(0.05, current / 1.25));
|
||||
const dialogSize = useMemo(() => {
|
||||
if (!naturalSize || naturalSize.url !== image.url) {
|
||||
return { width: "min(92vw, 880px)", height: "min(92vh, 680px)" };
|
||||
}
|
||||
return {
|
||||
width: `min(92vw, ${naturalSize.width}px)`,
|
||||
height: `min(92vh, ${naturalSize.height + 112}px)`,
|
||||
};
|
||||
}, [image.url, naturalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setNaturalSize({
|
||||
url: image.url,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
};
|
||||
img.src = image.url;
|
||||
}, [image.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return;
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
scroller.scrollLeft = dragState.scrollLeft - (event.clientX - dragState.startX);
|
||||
scroller.scrollTop = dragState.scrollTop - (event.clientY - dragState.startY);
|
||||
};
|
||||
const handleUp = () => setDragState(null);
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragState]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/42 p-6 backdrop-blur-[1px]"
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
setZoom((current) =>
|
||||
event.deltaY < 0
|
||||
? current * 1.18
|
||||
: Math.max(0.05, current / 1.18),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col overflow-hidden rounded-lg bg-white p-4 shadow-2xl ring-1 ring-zinc-200"
|
||||
style={dialogSize}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-4">
|
||||
<h2 className="truncate text-base font-semibold text-zinc-950">图片详情</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={
|
||||
dragState
|
||||
? "flex min-h-0 flex-1 cursor-grabbing overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
: "flex min-h-0 flex-1 cursor-grab overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
setDragState({
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scrollLeft: scroller.scrollLeft,
|
||||
scrollTop: scroller.scrollTop,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="m-auto">
|
||||
<img
|
||||
alt={image.title}
|
||||
className="block max-w-none select-none"
|
||||
draggable={false}
|
||||
src={image.url}
|
||||
style={{
|
||||
height: "auto",
|
||||
width: `${Math.max(
|
||||
80,
|
||||
zoom *
|
||||
(naturalSize?.url === image.url ? naturalSize.width : 960),
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-2 rounded-2xl border border-zinc-200 bg-white/92 px-3 py-2 shadow-[0_14px_42px_rgba(24,24,27,.14)] backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={() => setZoom(1)}
|
||||
title="适配视图"
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={zoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-12 text-center text-sm font-medium text-zinc-700">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={zoomIn}
|
||||
title="放大"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBlock({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-zinc-500">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-zinc-100 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">{label}</div>
|
||||
<div className="mt-1 truncate font-medium text-zinc-900" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadImage(url: string, filename: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function getAssetDownloadName(item: HistoryItem) {
|
||||
const safePrompt = (item.prompt || "asset-image")
|
||||
.slice(0, 32)
|
||||
.replace(/[\\/:*?"<>|]+/g, "-");
|
||||
return `${safePrompt || "asset-image"}.${item.outputFormat || "png"}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -52,6 +52,12 @@ import {
|
||||
resolutionOptions,
|
||||
sizeOptions,
|
||||
} from "@/lib/image-workflow";
|
||||
import {
|
||||
defaultCanvasSettings,
|
||||
normalizeCanvasSettings,
|
||||
type CanvasModelChannel,
|
||||
type CanvasSettings,
|
||||
} from "@/lib/canvas/settings";
|
||||
|
||||
type ActiveView = "studio" | "history" | "settings";
|
||||
type PreviewImage = { title: string; subtitle?: string; url: string };
|
||||
@@ -77,6 +83,7 @@ const promptPresets = [
|
||||
|
||||
const defaultPrompt =
|
||||
"温馨治愈的幼儿园毕业纪实写真,画面干净明亮通透,柔和室内自然光,人物边缘清晰,色彩低饱和且高级,真实摄影质感,无文字水印。";
|
||||
const defaultDirectModel = "gpt-image-2-2k";
|
||||
const activeDirectJobStorageKey = "imagegen:direct:active-job";
|
||||
|
||||
type StoredDirectJob = {
|
||||
@@ -88,7 +95,14 @@ type StoredDirectJob = {
|
||||
export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
const [mode, setMode] = useState<ImageMode>("generate");
|
||||
const [prompt, setPrompt] = useState(defaultPrompt);
|
||||
const [model, setModel] = useState("gpt-image-2-2k");
|
||||
const [enableNegativePrompt, setEnableNegativePrompt] = useState(false);
|
||||
const [negativePrompt, setNegativePrompt] = useState("");
|
||||
const [model, setModel] = useState(defaultDirectModel);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState(
|
||||
defaultCanvasSettings.selectedChannelId,
|
||||
);
|
||||
const [canvasSettings, setCanvasSettings] =
|
||||
useState<CanvasSettings>(defaultCanvasSettings);
|
||||
const [ratio, setRatio] = useState("1:1");
|
||||
const [resolution, setResolution] = useState<Resolution>("1K");
|
||||
const [quality, setQuality] = useState("high");
|
||||
@@ -133,10 +147,25 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
: size;
|
||||
const promptLength = useMemo(() => prompt.trim().length, [prompt]);
|
||||
const activeImage = history[0];
|
||||
const activeChannel = useMemo(
|
||||
() =>
|
||||
canvasSettings.channels.find((channel) => channel.id === selectedChannelId) ??
|
||||
canvasSettings.channels[0],
|
||||
[canvasSettings.channels, selectedChannelId],
|
||||
);
|
||||
const modelOptions = useMemo(() => {
|
||||
const channelModels = activeChannel?.models ?? [];
|
||||
return model && !channelModels.includes(model)
|
||||
? [model, ...channelModels]
|
||||
: channelModels;
|
||||
}, [activeChannel, model]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
void loadSettings();
|
||||
queueMicrotask(() => {
|
||||
void loadCanvasSettings();
|
||||
});
|
||||
|
||||
const storedJob = readStoredDirectJob();
|
||||
if (storedJob) {
|
||||
@@ -189,7 +218,7 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
async function loadHistory() {
|
||||
setIsHistoryLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/history", { cache: "no-store" });
|
||||
const response = await fetch("/api/history?source=direct", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "读取历史失败");
|
||||
@@ -215,6 +244,31 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCanvasSettings() {
|
||||
try {
|
||||
const response = await fetch("/api/canvas/settings", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "读取渠道设置失败");
|
||||
}
|
||||
const settings = normalizeCanvasSettings(payload.item);
|
||||
const channel =
|
||||
settings.channels.find((item) => item.id === settings.selectedChannelId) ??
|
||||
settings.channels[0];
|
||||
const nextModel =
|
||||
settings.defaultModels.image ||
|
||||
settings.modelPreferences.image[0] ||
|
||||
channel?.models[0] ||
|
||||
defaultDirectModel;
|
||||
|
||||
setCanvasSettings(settings);
|
||||
setSelectedChannelId(channel?.id ?? settings.selectedChannelId);
|
||||
setModel(nextModel);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "读取渠道设置失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
setIsSavingSettings(true);
|
||||
try {
|
||||
@@ -249,13 +303,18 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("source", "direct");
|
||||
formData.append("mode", mode);
|
||||
formData.append("prompt", prompt);
|
||||
if (enableNegativePrompt && negativePrompt.trim()) {
|
||||
formData.append("negativePrompt", negativePrompt.trim());
|
||||
}
|
||||
formData.append("model", model);
|
||||
formData.append("size", effectiveSize);
|
||||
formData.append("quality", quality);
|
||||
formData.append("outputFormat", outputFormat);
|
||||
formData.append("preserveIdentity", String(preserveIdentity));
|
||||
appendChannelConfig(formData, activeChannel);
|
||||
if (file) {
|
||||
formData.append("image", file);
|
||||
}
|
||||
@@ -428,27 +487,117 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
</TabsList>
|
||||
<TabsContent className="space-y-4" value="generate">
|
||||
<PromptEditor
|
||||
enableNegativePrompt={enableNegativePrompt}
|
||||
negativePrompt={negativePrompt}
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
setEnableNegativePrompt={setEnableNegativePrompt}
|
||||
setNegativePrompt={setNegativePrompt}
|
||||
promptLength={promptLength}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="space-y-4" value="edit">
|
||||
<PromptEditor
|
||||
enableNegativePrompt={enableNegativePrompt}
|
||||
negativePrompt={negativePrompt}
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
setEnableNegativePrompt={setEnableNegativePrompt}
|
||||
setNegativePrompt={setNegativePrompt}
|
||||
promptLength={promptLength}
|
||||
selectedImage={uploadedImageReference}
|
||||
onSelectUploadedImage={setReferencedImage}
|
||||
onClearSelectedImage={() => setReferencedImage(null)}
|
||||
/>
|
||||
<ImageUploader
|
||||
file={file}
|
||||
maskFile={maskFile}
|
||||
previewUrl={filePreviewUrl}
|
||||
maskPreviewUrl={maskPreviewUrl}
|
||||
setFile={setFile}
|
||||
setMaskFile={setMaskFile}
|
||||
onPreview={() =>
|
||||
filePreviewUrl
|
||||
? setPreviewImage({
|
||||
title: file?.name || "上传图片",
|
||||
subtitle: originalImageSize
|
||||
? `${originalImageSize.width}x${originalImageSize.height}`
|
||||
: undefined,
|
||||
url: filePreviewUrl,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onUseHistoryImage={placeImageUrlForEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field label="模型">
|
||||
<Input value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
</Field>
|
||||
<div className="rounded-md border border-zinc-200 bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-900">模型路由</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
生成时会随直接模式请求发送到后端
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
|
||||
直接模式
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Field label="渠道配置">
|
||||
<Select
|
||||
value={activeChannel?.id ?? ""}
|
||||
onValueChange={(nextChannelId) => {
|
||||
const nextChannel =
|
||||
canvasSettings.channels.find(
|
||||
(channel) => channel.id === nextChannelId,
|
||||
) ?? canvasSettings.channels[0];
|
||||
const nextModels = nextChannel?.models ?? [];
|
||||
const nextModel = nextModels.includes(model)
|
||||
? model
|
||||
: nextModels[0] || model;
|
||||
setSelectedChannelId(nextChannel?.id ?? nextChannelId);
|
||||
setModel(nextModel);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{canvasSettings.channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型名称">
|
||||
{modelOptions.length ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((modelName) => (
|
||||
<SelectItem key={modelName} value={modelName}>
|
||||
{modelName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="先在渠道配置中添加模型"
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="输出格式">
|
||||
<Select value={outputFormat} onValueChange={setOutputFormat}>
|
||||
@@ -507,27 +656,6 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<ImageUploader
|
||||
file={file}
|
||||
maskFile={maskFile}
|
||||
previewUrl={filePreviewUrl}
|
||||
maskPreviewUrl={maskPreviewUrl}
|
||||
setFile={setFile}
|
||||
setMaskFile={setMaskFile}
|
||||
onPreview={() =>
|
||||
filePreviewUrl
|
||||
? setPreviewImage({
|
||||
title: file?.name || "上传图片",
|
||||
subtitle: originalImageSize
|
||||
? `${originalImageSize.width}x${originalImageSize.height}`
|
||||
: undefined,
|
||||
url: filePreviewUrl,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onUseHistoryImage={placeImageUrlForEdit}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">保持原始尺寸</div>
|
||||
@@ -707,6 +835,21 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
function appendChannelConfig(
|
||||
formData: FormData,
|
||||
channel: CanvasModelChannel | undefined,
|
||||
) {
|
||||
if (!channel) return;
|
||||
formData.append("channelId", channel.id);
|
||||
formData.append("channelName", channel.name);
|
||||
if (channel.baseUrl.trim()) {
|
||||
formData.append("channelBaseUrl", channel.baseUrl.trim());
|
||||
}
|
||||
if (channel.apiKey.trim()) {
|
||||
formData.append("channelApiKey", channel.apiKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function ImageUploader({
|
||||
file,
|
||||
maskFile,
|
||||
@@ -835,15 +978,23 @@ function ImageUploader({
|
||||
}
|
||||
|
||||
function PromptEditor({
|
||||
enableNegativePrompt,
|
||||
negativePrompt,
|
||||
prompt,
|
||||
setPrompt,
|
||||
setEnableNegativePrompt,
|
||||
setNegativePrompt,
|
||||
promptLength,
|
||||
selectedImage,
|
||||
onSelectUploadedImage,
|
||||
onClearSelectedImage,
|
||||
}: {
|
||||
enableNegativePrompt: boolean;
|
||||
negativePrompt: string;
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
setEnableNegativePrompt: (value: boolean) => void;
|
||||
setNegativePrompt: (value: string) => void;
|
||||
promptLength: number;
|
||||
selectedImage?: UploadedImageReference | null;
|
||||
onSelectUploadedImage?: (value: UploadedImageReference | null) => void;
|
||||
@@ -872,6 +1023,29 @@ function PromptEditor({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">添加反向提示词</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
开启后会作为排除内容随生成请求发送
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enableNegativePrompt}
|
||||
onCheckedChange={setEnableNegativePrompt}
|
||||
/>
|
||||
</div>
|
||||
{enableNegativePrompt ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="negative-prompt">反向提示词</Label>
|
||||
<Textarea
|
||||
id="negative-prompt"
|
||||
placeholder="例如:不要文字、水印、畸形手指、低清晰度"
|
||||
value={negativePrompt}
|
||||
onChange={(event) => setNegativePrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedImage ? (
|
||||
<div className="flex items-center gap-3 rounded-md border border-zinc-200 bg-zinc-50 p-2">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded bg-white">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Aperture,
|
||||
Images,
|
||||
ImageIcon,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
@@ -15,10 +16,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AssetManager } from "@/components/direct/asset-manager";
|
||||
import { CanvasProjectsClient } from "@/components/canvas/canvas-projects-client";
|
||||
import { DirectStudio } from "@/components/direct/direct-studio";
|
||||
|
||||
export type ActiveView = "studio" | "history" | "settings" | "canvas";
|
||||
export type ActiveView = "studio" | "history" | "assets" | "settings" | "canvas";
|
||||
|
||||
export function HomeShell({
|
||||
initialView = "studio",
|
||||
@@ -49,7 +51,11 @@ export function HomeShell({
|
||||
},
|
||||
history: {
|
||||
title: "作品记录",
|
||||
description: "查看本次会话生成的图片与任务参数",
|
||||
description: "查看直接模式生成的图片与任务参数",
|
||||
},
|
||||
assets: {
|
||||
title: "资产管理",
|
||||
description: "按分类管理所有生成内容、备注和删除",
|
||||
},
|
||||
settings: {
|
||||
title: "模型设置",
|
||||
@@ -106,6 +112,15 @@ export function HomeShell({
|
||||
<ImageIcon />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>作品记录</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "assets" ? "secondary" : "ghost"}
|
||||
title="资产管理"
|
||||
onClick={() => handleViewChange("assets")}
|
||||
>
|
||||
<Images />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>资产管理</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "settings" ? "secondary" : "ghost"}
|
||||
@@ -163,6 +178,8 @@ export function HomeShell({
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{routeView === "canvas" ? (
|
||||
<CanvasProjectsClient />
|
||||
) : routeView === "assets" ? (
|
||||
<AssetManager />
|
||||
) : (
|
||||
<DirectStudio activeView={routeView} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[240] bg-black/45 backdrop-blur-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-[250] grid w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-zinc-200 bg-white p-5 shadow-[0_28px_90px_rgba(24,24,27,.24)] outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn("text-base font-semibold text-zinc-950", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn("text-sm leading-6 text-zinc-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(
|
||||
buttonVariants({ className: "bg-red-600 text-white hover:bg-red-700" }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
|
||||
{
|
||||
variants: {
|
||||
|
||||
@@ -18,6 +18,7 @@ export type CanvasGenerationInput = {
|
||||
imageNode?: CanvasNode;
|
||||
imageNodes: CanvasNode[];
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
config: CanvasConfigNodeMetadata;
|
||||
};
|
||||
|
||||
@@ -71,6 +72,11 @@ export function resolveCanvasGenerationInput(
|
||||
: connectedPrompts.length
|
||||
? formatConnectedPrompts(connectedPrompts)
|
||||
: (resolvedMentions?.prompt ?? "");
|
||||
const negativePrompt = resolveNegativePrompt(
|
||||
promptNode,
|
||||
resolvedMentions?.referencedNodeIds ? scopedNodes : nodes,
|
||||
scopedConnections,
|
||||
);
|
||||
|
||||
if (!promptNode || !prompt) {
|
||||
return { error: "请先添加并填写提示词节点或当前图像节点提示词" };
|
||||
@@ -90,6 +96,7 @@ export function resolveCanvasGenerationInput(
|
||||
imageNode: imageNodes[0],
|
||||
imageNodes: imageNodes.slice(0, 16),
|
||||
prompt,
|
||||
negativePrompt,
|
||||
config: {
|
||||
...defaultCanvasConfig,
|
||||
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
|
||||
@@ -98,10 +105,30 @@ export function resolveCanvasGenerationInput(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNegativePrompt(
|
||||
promptNode: CanvasNode | undefined,
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
) {
|
||||
if (promptNode?.type !== "prompt") return "";
|
||||
const metadata = promptNode.metadata as CanvasPromptNodeMetadata;
|
||||
if (!metadata.enableNegativePrompt) return "";
|
||||
const rawNegativePrompt = metadata.negativePrompt?.trim() || "";
|
||||
if (!rawNegativePrompt) return "";
|
||||
|
||||
return (
|
||||
resolvePromptMentions(
|
||||
rawNegativePrompt,
|
||||
getPromptMentionSources(nodes, connections, promptNode.id),
|
||||
)?.prompt ?? rawNegativePrompt
|
||||
).trim();
|
||||
}
|
||||
|
||||
function getImageGenerationConfig(node: CanvasNode | undefined) {
|
||||
if (node?.type !== "image") return {};
|
||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||
return {
|
||||
...(metadata.channelId ? { channelId: metadata.channelId } : {}),
|
||||
...(metadata.model ? { model: metadata.model } : {}),
|
||||
...(metadata.size ? { size: metadata.size } : {}),
|
||||
...(metadata.quality ? { quality: metadata.quality } : {}),
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DEFAULT_IMAGE_WIDTH = 320;
|
||||
export const DEFAULT_IMAGE_HEIGHT = 240;
|
||||
|
||||
export const defaultCanvasConfig: CanvasConfigNodeMetadata = {
|
||||
channelId: "",
|
||||
model: "gpt-image-2-2k",
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
@@ -89,6 +90,7 @@ export function createImageNodeFromHistory(
|
||||
size: item.size,
|
||||
quality: item.quality,
|
||||
outputFormat: item.outputFormat,
|
||||
fileSize: item.fileSize,
|
||||
status: "success",
|
||||
},
|
||||
};
|
||||
@@ -128,6 +130,7 @@ export function createImageResultNode(
|
||||
size: result.size,
|
||||
quality: result.quality,
|
||||
outputFormat: result.outputFormat,
|
||||
fileSize: result.fileSize,
|
||||
status: "success",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
export type CanvasModelChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export type CanvasModelKind = "image" | "video" | "text" | "audio";
|
||||
|
||||
export type CanvasModelPreferences = Record<CanvasModelKind, string[]>;
|
||||
|
||||
export type CanvasDefaultModels = Record<CanvasModelKind, string>;
|
||||
|
||||
export type CanvasSettings = {
|
||||
hideToolbarText: boolean;
|
||||
showImageInfoBadge: boolean;
|
||||
selectedChannelId: string;
|
||||
selectedModel: string;
|
||||
modelPreferences: CanvasModelPreferences;
|
||||
defaultModels: CanvasDefaultModels;
|
||||
channels: CanvasModelChannel[];
|
||||
};
|
||||
|
||||
export const defaultCanvasChannel: CanvasModelChannel = {
|
||||
id: "default",
|
||||
name: "默认渠道",
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "",
|
||||
models: ["gpt-image-2"],
|
||||
};
|
||||
|
||||
export const defaultCanvasSettings: CanvasSettings = {
|
||||
hideToolbarText: false,
|
||||
showImageInfoBadge: false,
|
||||
selectedChannelId: defaultCanvasChannel.id,
|
||||
selectedModel: defaultCanvasChannel.models[0],
|
||||
modelPreferences: {
|
||||
image: [defaultCanvasChannel.models[0]],
|
||||
video: [],
|
||||
text: [],
|
||||
audio: [],
|
||||
},
|
||||
defaultModels: {
|
||||
image: defaultCanvasChannel.models[0],
|
||||
video: "",
|
||||
text: "",
|
||||
audio: "",
|
||||
},
|
||||
channels: [defaultCanvasChannel],
|
||||
};
|
||||
|
||||
export function normalizeCanvasSettings(value: unknown): CanvasSettings {
|
||||
if (!value || typeof value !== "object") return defaultCanvasSettings;
|
||||
const candidate = value as Partial<CanvasSettings> & {
|
||||
modelName?: string;
|
||||
baseUrl?: string;
|
||||
apiKeySource?: string;
|
||||
};
|
||||
|
||||
// 兼容旧版 localStorage 设置:旧结构只有 modelName/baseUrl/apiKeySource,
|
||||
// 新结构统一用 channels JSON 存储,便于未来继续扩展渠道参数。
|
||||
if (!Array.isArray(candidate.channels)) {
|
||||
const legacyModel = typeof candidate.modelName === "string"
|
||||
? candidate.modelName
|
||||
: defaultCanvasSettings.selectedModel;
|
||||
const legacyChannel = {
|
||||
...defaultCanvasChannel,
|
||||
baseUrl:
|
||||
typeof candidate.baseUrl === "string" && candidate.baseUrl.startsWith("http")
|
||||
? candidate.baseUrl
|
||||
: defaultCanvasChannel.baseUrl,
|
||||
models: [legacyModel],
|
||||
};
|
||||
return {
|
||||
hideToolbarText: Boolean(candidate.hideToolbarText),
|
||||
showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
|
||||
selectedChannelId: legacyChannel.id,
|
||||
selectedModel: legacyModel,
|
||||
modelPreferences: {
|
||||
image: [legacyModel],
|
||||
video: [],
|
||||
text: [],
|
||||
audio: [],
|
||||
},
|
||||
defaultModels: {
|
||||
image: legacyModel,
|
||||
video: "",
|
||||
text: "",
|
||||
audio: "",
|
||||
},
|
||||
channels: [legacyChannel],
|
||||
};
|
||||
}
|
||||
|
||||
const channels = candidate.channels
|
||||
.map((channel) => normalizeCanvasChannel(channel))
|
||||
.filter((channel): channel is CanvasModelChannel => Boolean(channel));
|
||||
const normalizedChannels = channels.length ? channels : [defaultCanvasChannel];
|
||||
const selectedChannelId =
|
||||
typeof candidate.selectedChannelId === "string" &&
|
||||
normalizedChannels.some((channel) => channel.id === candidate.selectedChannelId)
|
||||
? candidate.selectedChannelId
|
||||
: normalizedChannels[0].id;
|
||||
const selectedChannel =
|
||||
normalizedChannels.find((channel) => channel.id === selectedChannelId) ??
|
||||
normalizedChannels[0];
|
||||
const selectedModel =
|
||||
typeof candidate.selectedModel === "string" &&
|
||||
candidate.selectedModel.trim() &&
|
||||
selectedChannel.models.includes(candidate.selectedModel)
|
||||
? candidate.selectedModel
|
||||
: selectedChannel.models[0] ?? defaultCanvasSettings.selectedModel;
|
||||
const modelPreferences = normalizeModelPreferences(
|
||||
candidate.modelPreferences,
|
||||
selectedModel,
|
||||
);
|
||||
const defaultModels = normalizeDefaultModels(
|
||||
candidate.defaultModels,
|
||||
modelPreferences,
|
||||
selectedModel,
|
||||
);
|
||||
|
||||
return {
|
||||
hideToolbarText: Boolean(candidate.hideToolbarText),
|
||||
showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
|
||||
selectedChannelId,
|
||||
selectedModel: defaultModels.image || selectedModel,
|
||||
modelPreferences,
|
||||
defaultModels,
|
||||
channels: normalizedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelPreferences(
|
||||
value: unknown,
|
||||
selectedModel: string,
|
||||
): CanvasModelPreferences {
|
||||
const candidate =
|
||||
value && typeof value === "object"
|
||||
? (value as Partial<CanvasModelPreferences>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
image: normalizeModelList(candidate.image, selectedModel ? [selectedModel] : []),
|
||||
video: normalizeModelList(candidate.video, []),
|
||||
text: normalizeModelList(candidate.text, []),
|
||||
audio: normalizeModelList(candidate.audio, []),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaultModels(
|
||||
value: unknown,
|
||||
preferences: CanvasModelPreferences,
|
||||
selectedModel: string,
|
||||
): CanvasDefaultModels {
|
||||
const candidate =
|
||||
value && typeof value === "object"
|
||||
? (value as Partial<CanvasDefaultModels>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
image: normalizeDefaultModel(candidate.image, preferences.image, selectedModel),
|
||||
video: normalizeDefaultModel(candidate.video, preferences.video, ""),
|
||||
text: normalizeDefaultModel(candidate.text, preferences.text, ""),
|
||||
audio: normalizeDefaultModel(candidate.audio, preferences.audio, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaultModel(
|
||||
value: unknown,
|
||||
models: string[],
|
||||
fallback: string,
|
||||
) {
|
||||
return typeof value === "string" && models.includes(value)
|
||||
? value
|
||||
: models[0] ?? fallback;
|
||||
}
|
||||
|
||||
function normalizeModelList(value: unknown, fallback: string[]) {
|
||||
const list = Array.isArray(value) ? value : fallback;
|
||||
return Array.from(
|
||||
new Set(
|
||||
list.filter((model): model is string =>
|
||||
typeof model === "string" && Boolean(model.trim()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<CanvasModelChannel>;
|
||||
const id = typeof candidate.id === "string" && candidate.id.trim()
|
||||
? candidate.id
|
||||
: crypto.randomUUID();
|
||||
const name = typeof candidate.name === "string" && candidate.name.trim()
|
||||
? candidate.name.trim()
|
||||
: "未命名渠道";
|
||||
const baseUrl = typeof candidate.baseUrl === "string"
|
||||
? candidate.baseUrl.trim()
|
||||
: "";
|
||||
const apiKey = typeof candidate.apiKey === "string" ? candidate.apiKey : "";
|
||||
const models = Array.isArray(candidate.models)
|
||||
? candidate.models.filter((model): model is string => typeof model === "string" && Boolean(model.trim()))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models,
|
||||
};
|
||||
}
|
||||
+17
-1
@@ -17,9 +17,12 @@ export const CANVAS_PROJECT_DATA_VERSION = 1;
|
||||
|
||||
export type CanvasPromptNodeMetadata = {
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
enableNegativePrompt?: boolean;
|
||||
};
|
||||
|
||||
export type CanvasConfigNodeMetadata = {
|
||||
channelId?: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
@@ -33,6 +36,7 @@ export type CanvasImageNodeMetadata = {
|
||||
filePath?: string;
|
||||
mode: "reference" | "result";
|
||||
prompt?: string;
|
||||
channelId?: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
@@ -41,6 +45,7 @@ export type CanvasImageNodeMetadata = {
|
||||
errorMessage?: string;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata =
|
||||
@@ -232,7 +237,15 @@ function normalizePromptMetadata(value: unknown): CanvasPromptNodeMetadata | nul
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.prompt !== "string") return null;
|
||||
return { prompt: candidate.prompt };
|
||||
return {
|
||||
prompt: candidate.prompt,
|
||||
...(typeof candidate.negativePrompt === "string"
|
||||
? { negativePrompt: candidate.negativePrompt }
|
||||
: {}),
|
||||
...(typeof candidate.enableNegativePrompt === "boolean"
|
||||
? { enableNegativePrompt: candidate.enableNegativePrompt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | null {
|
||||
@@ -248,6 +261,7 @@ function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | nul
|
||||
}
|
||||
|
||||
return {
|
||||
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
|
||||
model: candidate.model,
|
||||
size: candidate.size,
|
||||
quality: candidate.quality,
|
||||
@@ -275,6 +289,7 @@ function normalizeImageMetadata(value: unknown): CanvasImageNodeMetadata | null
|
||||
...(typeof candidate.filePath === "string" ? { filePath: candidate.filePath } : {}),
|
||||
mode: candidate.mode,
|
||||
...(typeof candidate.prompt === "string" ? { prompt: candidate.prompt } : {}),
|
||||
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
|
||||
...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
|
||||
...(typeof candidate.size === "string" ? { size: candidate.size } : {}),
|
||||
...(typeof candidate.quality === "string" ? { quality: candidate.quality } : {}),
|
||||
@@ -291,6 +306,7 @@ function normalizeImageMetadata(value: unknown): CanvasImageNodeMetadata | null
|
||||
...(typeof candidate.naturalHeight === "number"
|
||||
? { naturalHeight: candidate.naturalHeight }
|
||||
: {}),
|
||||
...(typeof candidate.fileSize === "number" ? { fileSize: candidate.fileSize } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ImageJobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type HistoryItem = {
|
||||
id: string;
|
||||
source?: "direct" | "canvas";
|
||||
mode: ImageMode;
|
||||
prompt: string;
|
||||
model: string;
|
||||
@@ -11,6 +12,8 @@ export type HistoryItem = {
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
imageUrl: string;
|
||||
fileSize?: number;
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,16 @@ export function getDb() {
|
||||
db.exec("ALTER TABLE canvas_projects ADD COLUMN description TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
const historyColumns = db
|
||||
.prepare("PRAGMA table_info(history)")
|
||||
.all() as Array<{ name: string }>;
|
||||
if (!historyColumns.some((column) => column.name === "source")) {
|
||||
db.exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'direct'");
|
||||
}
|
||||
if (!historyColumns.some((column) => column.name === "note")) {
|
||||
db.exec("ALTER TABLE history ADD COLUMN note TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
|
||||
).run();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getDb } from "@/lib/server/db";
|
||||
|
||||
export function getJsonSetting<T>(key: string, fallback: T): T {
|
||||
const row = getDb()
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(key) as { value?: string } | undefined;
|
||||
if (!row?.value) return fallback;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function setJsonSetting<T>(key: string, value: T) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
)
|
||||
.run(key, JSON.stringify(value));
|
||||
return value;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getImageDir } from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
export type StoredImage = {
|
||||
id: string;
|
||||
source: HistorySource;
|
||||
mode: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
@@ -16,9 +17,17 @@ export type StoredImage = {
|
||||
outputFormat: string;
|
||||
imageUrl: string;
|
||||
filePath: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type HistorySource = "direct" | "canvas";
|
||||
|
||||
export type ListHistoryOptions = {
|
||||
source?: HistorySource | "all";
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
let cleanupTimerStarted = false;
|
||||
|
||||
export function startHistoryCleanupTimer() {
|
||||
@@ -57,27 +66,64 @@ export function insertHistory(record: StoredImage) {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO history
|
||||
(id, mode, prompt, model, size, quality, output_format, image_url, file_path, created_at)
|
||||
(id, source, mode, prompt, model, size, quality, output_format, image_url, file_path, note, created_at)
|
||||
VALUES
|
||||
(@id, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @createdAt)`,
|
||||
(@id, @source, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @note, @createdAt)`,
|
||||
)
|
||||
.run(record);
|
||||
}
|
||||
|
||||
export function listHistory() {
|
||||
export function listHistory(options: ListHistoryOptions = {}) {
|
||||
cleanupExpiredHistory();
|
||||
const limit = normalizeLimit(options.limit);
|
||||
if (options.source && options.source !== "all") {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT id, source, mode, prompt, model, size, quality,
|
||||
output_format AS outputFormat,
|
||||
image_url AS imageUrl,
|
||||
file_path AS filePath,
|
||||
note,
|
||||
created_at AS createdAt
|
||||
FROM history
|
||||
WHERE source = @source
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit`,
|
||||
)
|
||||
.all({ source: options.source, limit }) as StoredImage[];
|
||||
}
|
||||
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT id, mode, prompt, model, size, quality,
|
||||
`SELECT id, source, mode, prompt, model, size, quality,
|
||||
output_format AS outputFormat,
|
||||
image_url AS imageUrl,
|
||||
file_path AS filePath,
|
||||
note,
|
||||
created_at AS createdAt
|
||||
FROM history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
LIMIT @limit`,
|
||||
)
|
||||
.all() as StoredImage[];
|
||||
.all({ limit }) as StoredImage[];
|
||||
}
|
||||
|
||||
export function deleteHistoryItem(id: string) {
|
||||
const row = getDb()
|
||||
.prepare("SELECT file_path AS filePath FROM history WHERE id = ?")
|
||||
.get(id) as Pick<StoredImage, "filePath"> | undefined;
|
||||
if (!row) return false;
|
||||
|
||||
getDb().prepare("DELETE FROM history WHERE id = ?").run(id);
|
||||
cleanupOrphanedImages();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateHistoryNote(id: string, note: string) {
|
||||
const result = getDb()
|
||||
.prepare("UPDATE history SET note = ? WHERE id = ?")
|
||||
.run(note, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function cleanupExpiredHistory() {
|
||||
@@ -168,3 +214,8 @@ function removeFile(filePath: string) {
|
||||
// Ignore deletion errors so history cleanup cannot break generation.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined) {
|
||||
if (!Number.isFinite(limit)) return 100;
|
||||
return Math.max(1, Math.min(500, Math.floor(limit!)));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
insertHistory,
|
||||
type StoredImage,
|
||||
} from "@/lib/server/repositories/history-repository";
|
||||
import { persistBase64Image } from "@/lib/server/storage/image-file-storage";
|
||||
import {
|
||||
getGeneratedImagePathFromUrl,
|
||||
persistBase64Image,
|
||||
readPersistedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
const identityGuard =
|
||||
"When editing a person or group photo, strictly preserve all original identities, facial features, expressions, pose, clothing layout, body proportions, and composition. Do not add, remove, crop, replace, redraw, or distort any person. Change only the requested lighting, color, background polish, clarity, and photographic finish.";
|
||||
@@ -15,6 +19,7 @@ const openAIMaxRetries = 0;
|
||||
const jobRetentionMs = 60 * 60 * 1000;
|
||||
|
||||
type ImageMode = "generate" | "edit";
|
||||
type ImageSource = "direct" | "canvas";
|
||||
type OutputFormat = "png" | "jpeg" | "webp";
|
||||
type Quality = "low" | "medium" | "high" | "auto";
|
||||
type LogLevel = "info" | "error";
|
||||
@@ -28,20 +33,31 @@ export type UploadedFile = {
|
||||
};
|
||||
|
||||
type ImageJobInput = {
|
||||
source: ImageSource;
|
||||
mode: ImageMode;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
preserveIdentity: boolean;
|
||||
channelBaseUrl?: string;
|
||||
channelApiKey?: string;
|
||||
images: UploadedFile[];
|
||||
mask?: UploadedFile;
|
||||
};
|
||||
|
||||
type ImageReference = {
|
||||
imageUrl?: string;
|
||||
filePath?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
type ImageJobResult = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
fileSize?: number;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: Quality;
|
||||
@@ -101,15 +117,18 @@ export async function submitImageJobRequest(request: Request) {
|
||||
phase = "cleanup";
|
||||
cleanupExpiredHistory();
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const baseURL = process.env.OPENAI_BASE_URL;
|
||||
phase = "parse-form";
|
||||
const formData = await request.formData();
|
||||
const input = await parseImageJobInput(formData);
|
||||
const apiKey = input.channelApiKey || process.env.OPENAI_API_KEY;
|
||||
const baseURL = input.channelBaseUrl || process.env.OPENAI_BASE_URL;
|
||||
|
||||
if (!apiKey) {
|
||||
phase = "validate-env";
|
||||
log("error", "missing OPENAI_API_KEY");
|
||||
log("error", "missing API key");
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: "Missing OPENAI_API_KEY in .env.local",
|
||||
error: "请在渠道配置中填写 API Key,或在 .env.local 中配置 OPENAI_API_KEY",
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
@@ -119,10 +138,6 @@ export async function submitImageJobRequest(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
phase = "parse-form";
|
||||
const formData = await request.formData();
|
||||
const input = await parseImageJobInput(formData);
|
||||
|
||||
log("info", "form parsed", {
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
@@ -132,6 +147,10 @@ export async function submitImageJobRequest(request: Request) {
|
||||
preserveIdentity: input.preserveIdentity,
|
||||
promptChars: input.prompt.length,
|
||||
promptPreview: preview(input.prompt),
|
||||
negativePromptChars: input.negativePrompt?.length ?? 0,
|
||||
negativePromptPreview: input.negativePrompt
|
||||
? preview(input.negativePrompt)
|
||||
: undefined,
|
||||
imageCount: input.images.length,
|
||||
imageNames: input.images.map((image) => image.name),
|
||||
imageTypes: input.images.map((image) => image.type),
|
||||
@@ -141,6 +160,7 @@ export async function submitImageJobRequest(request: Request) {
|
||||
maskType: input.mask?.type,
|
||||
maskBytes: input.mask?.size,
|
||||
baseURL: baseURL ? redactUrl(baseURL) : "default",
|
||||
channelConfigured: Boolean(input.channelApiKey || input.channelBaseUrl),
|
||||
});
|
||||
|
||||
phase = "validate-input";
|
||||
@@ -391,10 +411,7 @@ async function runImageJob(
|
||||
maxRetries: openAIMaxRetries,
|
||||
});
|
||||
|
||||
const finalPrompt =
|
||||
input.preserveIdentity && input.mode === "edit"
|
||||
? `${input.prompt.trim()}\n\nConstraints: ${identityGuard}`
|
||||
: input.prompt.trim();
|
||||
const finalPrompt = buildFinalPrompt(input);
|
||||
|
||||
const common = {
|
||||
model: input.model,
|
||||
@@ -460,6 +477,7 @@ async function runImageJob(
|
||||
const resultPayload = {
|
||||
id,
|
||||
imageUrl: persisted.imageUrl,
|
||||
fileSize: persisted.fileSize,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
@@ -475,9 +493,11 @@ async function runImageJob(
|
||||
|
||||
const historyRecord: StoredImage = {
|
||||
...resultPayload,
|
||||
source: input.source,
|
||||
mode: input.mode,
|
||||
prompt: input.prompt,
|
||||
filePath: persisted.filePath,
|
||||
note: "",
|
||||
};
|
||||
insertHistory(historyRecord);
|
||||
|
||||
@@ -558,11 +578,29 @@ function serializeSseEvent(event: string, data: unknown) {
|
||||
|
||||
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||
const images = formData.getAll("image");
|
||||
const imageRefs = formData.getAll("imageRef");
|
||||
const mask = formData.get("mask");
|
||||
|
||||
// 兼容两种图片来源:
|
||||
// 1. image:前端直接上传的 File,例如用户本地上传、遮罩工具生成的新文件。
|
||||
// 2. imageRef:画布里已经持久化过的图片引用。后端根据 filePath/imageUrl 直读,
|
||||
// 避免浏览器因对象存储 CORS 无法 fetch,也避免大图在前后端之间重复传输。
|
||||
const uploadedImages = await Promise.all(
|
||||
images
|
||||
.filter((image): image is File => image instanceof File)
|
||||
.map((image) => readUploadedFile(image)),
|
||||
);
|
||||
const referencedImages = await Promise.all(
|
||||
imageRefs
|
||||
.filter((imageRef): imageRef is string => typeof imageRef === "string")
|
||||
.map((imageRef, index) => readReferencedImageFile(imageRef, index)),
|
||||
);
|
||||
|
||||
return {
|
||||
source: normalizeSource(getString(formData, "source", "direct")),
|
||||
mode: getString(formData, "mode", "generate") as ImageMode,
|
||||
prompt: getString(formData, "prompt"),
|
||||
negativePrompt: getString(formData, "negativePrompt").trim() || undefined,
|
||||
model: getString(formData, "model", "gpt-image-2-2k"),
|
||||
size: getString(formData, "size", "auto"),
|
||||
quality: normalizeQuality(getString(formData, "quality", "high")),
|
||||
@@ -570,15 +608,17 @@ async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||
getString(formData, "outputFormat", "png"),
|
||||
),
|
||||
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
|
||||
images: await Promise.all(
|
||||
images
|
||||
.filter((image): image is File => image instanceof File)
|
||||
.map((image) => readUploadedFile(image)),
|
||||
),
|
||||
channelBaseUrl: normalizeOptionalBaseUrl(getString(formData, "channelBaseUrl")),
|
||||
channelApiKey: getString(formData, "channelApiKey"),
|
||||
images: [...uploadedImages, ...referencedImages],
|
||||
mask: mask instanceof File ? await readUploadedFile(mask) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSource(value: string): ImageSource {
|
||||
return value === "canvas" ? "canvas" : "direct";
|
||||
}
|
||||
|
||||
function validateImageJobInput(input: ImageJobInput) {
|
||||
if (!input.prompt.trim()) {
|
||||
return "Prompt is required";
|
||||
@@ -591,6 +631,21 @@ function validateImageJobInput(input: ImageJobInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFinalPrompt(input: ImageJobInput) {
|
||||
const sections = [input.prompt.trim()];
|
||||
const negativePrompt = input.negativePrompt?.trim();
|
||||
|
||||
if (negativePrompt) {
|
||||
sections.push(`Negative prompt: ${negativePrompt}`);
|
||||
}
|
||||
|
||||
if (input.preserveIdentity && input.mode === "edit") {
|
||||
sections.push(`Constraints: ${identityGuard}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
async function readUploadedFile(file: File): Promise<UploadedFile> {
|
||||
return {
|
||||
buffer: Buffer.from(await file.arrayBuffer()),
|
||||
@@ -600,6 +655,114 @@ async function readUploadedFile(file: File): Promise<UploadedFile> {
|
||||
};
|
||||
}
|
||||
|
||||
async function readReferencedImageFile(rawReference: string, index: number) {
|
||||
const reference = parseImageReference(rawReference);
|
||||
if (!reference) {
|
||||
throw Object.assign(new Error("图片引用格式无效"), { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = resolveReadableImagePath(reference.imageUrl, reference.filePath);
|
||||
if (filePath) {
|
||||
// 优先使用服务端可信路径读取图片:
|
||||
// - s3://... 由服务端 SDK 走对象存储私有读,不依赖浏览器 CORS。
|
||||
// - /generated/... 反解到本地 public/generated 文件,省掉一次 HTTP 下载。
|
||||
const buffer = await readPersistedImageFile(filePath);
|
||||
if (!buffer) {
|
||||
throw Object.assign(new Error("图片文件不存在或无法读取"), { status: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
name: reference.filename || getFallbackImageName(filePath, index),
|
||||
type: getImageContentType(filePath),
|
||||
size: buffer.length,
|
||||
} satisfies UploadedFile;
|
||||
}
|
||||
|
||||
if (!reference.imageUrl) {
|
||||
throw Object.assign(new Error("图片引用缺少 imageUrl 或 filePath"), { status: 400 });
|
||||
}
|
||||
|
||||
// 有些历史数据可能没有 filePath,只保留了公开 imageUrl。
|
||||
// 这种情况才回退到服务端 fetch,并且下面会做严格域名白名单限制。
|
||||
const remoteImage = await readAllowedRemoteImage(reference.imageUrl);
|
||||
if (!remoteImage) {
|
||||
throw Object.assign(new Error("远程图片不可访问"), { status: 502 });
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: remoteImage.buffer,
|
||||
name: reference.filename || getFallbackImageName(reference.imageUrl, index),
|
||||
type: remoteImage.contentType,
|
||||
size: remoteImage.buffer.length,
|
||||
} satisfies UploadedFile;
|
||||
}
|
||||
|
||||
function parseImageReference(rawReference: string): ImageReference | null {
|
||||
try {
|
||||
const parsed = JSON.parse(rawReference) as Partial<ImageReference>;
|
||||
return {
|
||||
imageUrl: typeof parsed.imageUrl === "string" ? parsed.imageUrl.trim() : undefined,
|
||||
filePath: typeof parsed.filePath === "string" ? parsed.filePath.trim() : undefined,
|
||||
filename: typeof parsed.filename === "string" ? parsed.filename.trim() : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadableImagePath(imageUrl?: string, filePath?: string) {
|
||||
// 画布节点里保存的 filePath 是服务端可信路径。对象存储图片会是 s3://...,
|
||||
// 本地生成图片则可以从 /generated/... 反推出 public/generated 下的实际文件。
|
||||
if (filePath?.startsWith("s3://")) return filePath;
|
||||
if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readAllowedRemoteImage(imageUrl: string) {
|
||||
// 只有当节点没有可读 filePath 时才回退到远程 URL,并且只允许读取当前对象存储公开域名。
|
||||
// 这样既避开浏览器 CORS,也避免 /api/images 变成任意 URL 的服务端代理。
|
||||
if (!isAllowedRemoteImageUrl(imageUrl)) return null;
|
||||
|
||||
const response = await fetch(imageUrl, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/png";
|
||||
if (!contentType.startsWith("image/")) return null;
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedRemoteImageUrl(imageUrl: string) {
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "file.hanhan.ltd" &&
|
||||
url.pathname.startsWith("/imagegen-tools/generated/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackImageName(value: string, index: number) {
|
||||
const cleanName = value.split(/[\\/]/).at(-1)?.split("?")[0];
|
||||
return cleanName && cleanName.includes(".")
|
||||
? cleanName
|
||||
: `canvas-reference-${index + 1}.png`;
|
||||
}
|
||||
|
||||
function getImageContentType(filePath: string) {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase();
|
||||
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
|
||||
if (extension === "webp") return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
function cleanupOldJobs() {
|
||||
const cutoff = Date.now() - jobRetentionMs;
|
||||
for (const [jobId, job] of imageJobs.entries()) {
|
||||
@@ -654,6 +817,11 @@ function getString(formData: FormData, key: string, fallback = "") {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeOptionalBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
||||
}
|
||||
|
||||
function normalizeQuality(value: string): Quality {
|
||||
return ["low", "medium", "high", "auto"].includes(value)
|
||||
? (value as Quality)
|
||||
|
||||
@@ -13,6 +13,7 @@ export type PersistedImageFile = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
imageUrl: string;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
type S3StorageConfig = {
|
||||
@@ -92,7 +93,7 @@ function persistImageBufferLocally(
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
return { fileName, filePath, imageUrl, fileSize: buffer.length };
|
||||
}
|
||||
|
||||
async function persistImageBufferWithFileName(
|
||||
@@ -121,6 +122,7 @@ async function persistImageBufferWithFileName(
|
||||
fileName,
|
||||
filePath: `s3://${config.bucket}/${key}`,
|
||||
imageUrl: createPublicImageUrl(config, key),
|
||||
fileSize: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user