Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 520fa0aa89 |
@@ -1,2 +1,3 @@
|
||||
OPENAI_API_KEY=sk-your-key
|
||||
OPENAI_BASE_URL=https://api.kkrich.ltd/v1
|
||||
OPENAI_IMAGE_TIMEOUT_MS=600000
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
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 default function AssetsPage() {
|
||||
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);
|
||||
|
||||
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;
|
||||
const selectedVisibleIndex = visibleItems.findIndex(
|
||||
(item) => item.id === selectedItemId,
|
||||
);
|
||||
const canSelectPrevious = selectedVisibleIndex > 0;
|
||||
const canSelectNext =
|
||||
selectedVisibleIndex >= 0 && selectedVisibleIndex < visibleItems.length - 1;
|
||||
|
||||
function selectRelativeAsset(offset: -1 | 1) {
|
||||
const nextItem = visibleItems[selectedVisibleIndex + offset];
|
||||
if (nextItem) setSelectedItemId(nextItem.id);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSelectedItemId(null);
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
const previousItem = visibleItems[selectedVisibleIndex - 1];
|
||||
if (previousItem) setSelectedItemId(previousItem.id);
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
const nextItem = visibleItems[selectedVisibleIndex + 1];
|
||||
if (nextItem) setSelectedItemId(nextItem.id);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedItem, selectedVisibleIndex, visibleItems]);
|
||||
|
||||
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="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-2 gap-3 sm:columns-3 lg:columns-4 xl:columns-5 2xl:columns-6">
|
||||
{visibleItems.map((item) => {
|
||||
const isDeleting = deletingIds.includes(item.id);
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className={
|
||||
selectedItemId === item.id
|
||||
? "group mb-3 break-inside-avoid overflow-hidden rounded-md border border-zinc-950 bg-white shadow-xs ring-1 ring-zinc-950"
|
||||
: "group mb-3 break-inside-avoid overflow-hidden rounded-md border border-zinc-200 bg-white shadow-xs"
|
||||
}
|
||||
>
|
||||
<div className="relative bg-zinc-100">
|
||||
<button
|
||||
aria-pressed={selectedItemId === item.id}
|
||||
className="block w-full overflow-hidden text-left"
|
||||
title="查看图片详情"
|
||||
type="button"
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
>
|
||||
<img
|
||||
alt={item.prompt || "生成图片"}
|
||||
className="w-full cursor-pointer object-cover transition-transform duration-300 ease-out group-hover:scale-[1.035]"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
</button>
|
||||
{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="pointer-events-none 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 group-focus-within: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 ? (
|
||||
<AssetPreviewDialog
|
||||
draftNote={draftNotes[selectedItem.id] ?? ""}
|
||||
item={selectedItem}
|
||||
isSaving={savingIds.includes(selectedItem.id)}
|
||||
canSelectNext={canSelectNext}
|
||||
canSelectPrevious={canSelectPrevious}
|
||||
onClose={() => setSelectedItemId(null)}
|
||||
onDelete={(item) => void deleteAsset(item)}
|
||||
onNoteChange={(item, note) =>
|
||||
setDraftNotes((current) => ({
|
||||
...current,
|
||||
[item.id]: note,
|
||||
}))
|
||||
}
|
||||
onSaveNote={(item) => void saveNote(item)}
|
||||
onSelectNext={() => selectRelativeAsset(1)}
|
||||
onSelectPrevious={() => selectRelativeAsset(-1)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetPreviewDialog({
|
||||
canSelectNext,
|
||||
canSelectPrevious,
|
||||
draftNote,
|
||||
isSaving,
|
||||
item,
|
||||
onClose,
|
||||
onDelete,
|
||||
onNoteChange,
|
||||
onSaveNote,
|
||||
onSelectNext,
|
||||
onSelectPrevious,
|
||||
}: {
|
||||
canSelectNext: boolean;
|
||||
canSelectPrevious: boolean;
|
||||
draftNote: string;
|
||||
isSaving: boolean;
|
||||
item: HistoryItem;
|
||||
onClose: () => void;
|
||||
onDelete: (item: HistoryItem) => void;
|
||||
onNoteChange: (item: HistoryItem, note: string) => void;
|
||||
onSaveNote: (item: HistoryItem) => void;
|
||||
onSelectNext: () => void;
|
||||
onSelectPrevious: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label="资产图片详情"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-[200] bg-zinc-100"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(42vh,1fr)_minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_420px] lg:grid-rows-1">
|
||||
<section className="relative flex min-h-0 items-center justify-center overflow-hidden p-4 sm:p-6 lg:p-8">
|
||||
<Button
|
||||
className="absolute right-4 top-4 z-10 bg-white shadow-sm"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X/>
|
||||
</Button>
|
||||
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col gap-2">
|
||||
<Button
|
||||
className="bg-white shadow-sm"
|
||||
disabled={!canSelectPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSelectPrevious}
|
||||
title="上一张"
|
||||
>
|
||||
<ChevronUp/>
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-white shadow-sm"
|
||||
disabled={!canSelectNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSelectNext}
|
||||
title="下一张"
|
||||
>
|
||||
<ChevronDown/>
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
alt={item.prompt || "生成图片"}
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
|
||||
src={item.imageUrl}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 flex-col border-t border-zinc-200 bg-white lg:border-l lg:border-t-0">
|
||||
<div className="shrink-0 border-b border-zinc-200 px-5 py-4">
|
||||
<div className="text-sm font-semibold text-zinc-950">图片信息</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto p-5">
|
||||
<div className="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-5 space-y-5">
|
||||
<InfoBlock label="提示词">
|
||||
<p className="whitespace-pre-wrap 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)}
|
||||
/>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSaveNote(item)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin"/> : <Save/>}
|
||||
保存备注
|
||||
</Button>
|
||||
</InfoBlock>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 gap-2 border-t border-zinc-200 p-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
|
||||
>
|
||||
<Download/>
|
||||
下载
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => onDelete(item)}>
|
||||
<Trash2/>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
type EditingProjectState = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
} | null;
|
||||
|
||||
export default function CanvasProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
async function loadProjects() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
setProjects(Array.isArray(payload.items) ? payload.items : []);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "加载画布失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "未命名画布" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
const id = payload?.item?.id;
|
||||
if (id) {
|
||||
router.push(`/canvas/${id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "新建画布失败");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProjectEdit() {
|
||||
if (!editingProject || isSavingEdit) return;
|
||||
const title = editingProject.title.trim();
|
||||
if (!title) {
|
||||
toast.error("标题不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: editingProject.description,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.item) {
|
||||
throw new Error(payload.error || "保存画布信息失败");
|
||||
}
|
||||
const updatedProject = payload.item as CanvasProjectListItem;
|
||||
setProjects((current) =>
|
||||
current.map((project) =>
|
||||
project.id === updatedProject.id
|
||||
? {
|
||||
...project,
|
||||
title: updatedProject.title,
|
||||
description: updatedProject.description,
|
||||
updatedAt: updatedProject.updatedAt,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
);
|
||||
setEditingProject(null);
|
||||
toast.success("画布信息已更新");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(project: CanvasProjectListItem) {
|
||||
setDeletingProjectId(project.id);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${project.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "删除画布失败");
|
||||
}
|
||||
setProjects((current) => current.filter((item) => item.id !== project.id));
|
||||
toast.success("画布已删除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "删除画布失败");
|
||||
} finally {
|
||||
setDeletingProjectId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
|
||||
<Toaster richColors position="top-center" />
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">无限画布</h1>
|
||||
<p className="text-sm text-zinc-500">一画布一项目,支持节点流式生成。</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => void loadProjects()}>
|
||||
<RefreshCcw />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isCreating} onClick={() => void createProject()}>
|
||||
<Plus />
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-6 text-sm text-zinc-500">
|
||||
正在加载画布...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
className="min-w-0 flex-1"
|
||||
href={`/canvas/${project.id}`}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{project.title}</div>
|
||||
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
|
||||
{project.description || "暂无说明"}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-500">
|
||||
{project.nodeCount} 个节点 · 更新于{" "}
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<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={() =>
|
||||
setEditingProject({
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
})
|
||||
}
|
||||
title="编辑画布信息"
|
||||
>
|
||||
<Pencil 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>
|
||||
))}
|
||||
{!projects.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
||||
还没有画布,先新建一个。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProjectEditDialog
|
||||
project={editingProject}
|
||||
isSaving={isSavingEdit}
|
||||
onChange={setEditingProject}
|
||||
onClose={() => {
|
||||
if (!isSavingEdit) setEditingProject(null);
|
||||
}}
|
||||
onSave={() => void saveProjectEdit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectEditDialog({
|
||||
project,
|
||||
isSaving,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
project: EditingProjectState;
|
||||
isSaving: boolean;
|
||||
onChange: (project: EditingProjectState) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
|
||||
<div className="flex items-center justify-between gap-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"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="mt-5 block text-sm font-medium text-zinc-700">
|
||||
标题
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
|
||||
disabled={isSaving}
|
||||
maxLength={80}
|
||||
value={project.title}
|
||||
onChange={(event) =>
|
||||
onChange({ ...project, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-4 block text-sm font-medium text-zinc-700">
|
||||
说明
|
||||
<textarea
|
||||
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
|
||||
disabled={isSaving}
|
||||
maxLength={300}
|
||||
placeholder="给这个画布补充用途、阶段或备注"
|
||||
value={project.description}
|
||||
onChange={(event) =>
|
||||
onChange({ ...project, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={isSaving} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {usePathname} from "next/navigation";
|
||||
import {
|
||||
Aperture,
|
||||
Images,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
|
||||
type ActiveView = "studio" | "assets" | "settings" | "canvas";
|
||||
|
||||
export default function ManagerLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const routeView = getActiveView(pathname);
|
||||
|
||||
const pageMeta = {
|
||||
studio: {
|
||||
title: "图片生成应用",
|
||||
description: "直接模式:文生图、图生图、历史查看",
|
||||
},
|
||||
assets: {
|
||||
title: "资产管理",
|
||||
description: "按分类管理所有生成内容、备注和删除",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
description: "统一管理渠道、模型、画布界面和历史记录",
|
||||
},
|
||||
canvas: {
|
||||
title: "无限画布",
|
||||
description: "一画布一项目,支持节点流式生成",
|
||||
},
|
||||
}[routeView];
|
||||
|
||||
return (
|
||||
<main className="h-screen w-full overflow-hidden bg-zinc-100 text-zinc-950">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "grid min-h-screen overflow-x-hidden lg:grid-cols-[72px_minmax(0,1fr)]"
|
||||
: "grid min-h-screen overflow-x-hidden lg:grid-cols-[248px_minmax(0,1fr)]"
|
||||
}
|
||||
>
|
||||
<aside className="hidden h-full overflow-hidden border-r border-zinc-200 bg-white lg:block">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "flex h-14 items-center justify-center border-b border-zinc-200 px-3"
|
||||
: "flex h-14 items-center gap-2 border-b border-zinc-200 px-4"
|
||||
}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-zinc-950 text-white">
|
||||
<Aperture className="size-4"/>
|
||||
</div>
|
||||
<div className={sidebarCollapsed ? "hidden" : ""}>
|
||||
<div className="text-sm font-semibold">Image Studio</div>
|
||||
<div className="text-xs text-zinc-500">direct mode</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex h-[calc(100%-56px)] flex-col px-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
asChild
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "studio" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Link href="/" title="生成工作台">
|
||||
<Sparkles/>
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>生成工作台</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "assets" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Link href="/assets" title="资产管理">
|
||||
<Images/>
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>资产管理</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "settings" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Link href="/settings" title="设置">
|
||||
<Settings2/>
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>设置</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "canvas" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Link href="/canvas" title="无限画布">
|
||||
<Workflow/>
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>无限画布</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="mt-auto flex flex-col items-center justify-center border-t border-zinc-200 pt-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={sidebarCollapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
>
|
||||
<PanelLeft className="lg:hidden"/>
|
||||
{sidebarCollapsed ? "" : ""}
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftOpen className="hidden lg:block"/>
|
||||
) : (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<PanelLeftClose className="hidden lg:block"/>
|
||||
折叠侧边栏
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex h-screen min-w-0 flex-col overflow-hidden">
|
||||
<header
|
||||
className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 bg-white px-4 lg:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">{pageMeta.title}</h1>
|
||||
<p className="text-xs text-zinc-500">{pageMeta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function getActiveView(pathname: string): ActiveView {
|
||||
if (pathname.startsWith("/assets")) return "assets";
|
||||
if (pathname.startsWith("/settings")) return "settings";
|
||||
if (pathname.startsWith("/canvas")) return "canvas";
|
||||
return "studio";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { ChannelSettingsPanel } from "@/components/canvas/canvas-editor-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
defaultCanvasSettings,
|
||||
normalizeCanvasSettings,
|
||||
type CanvasSettings,
|
||||
} from "@/lib/canvas/settings";
|
||||
|
||||
type SettingsTab = "channels" | "interface" | "history";
|
||||
|
||||
const settingsTabs = [
|
||||
{ id: "channels", label: "渠道配置" },
|
||||
{ id: "interface", label: "界面设置" },
|
||||
{ id: "history", label: "历史记录" },
|
||||
] satisfies Array<{ id: SettingsTab; label: string }>;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("channels");
|
||||
const [retentionDays, setRetentionDays] = useState("7");
|
||||
const [canvasSettings, setCanvasSettings] =
|
||||
useState<CanvasSettings>(defaultCanvasSettings);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
async function loadInitialSettings() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [appResponse, canvasResponse] = await Promise.all([
|
||||
fetch("/api/settings", { cache: "no-store" }),
|
||||
fetch("/api/canvas/settings", { cache: "no-store" }),
|
||||
]);
|
||||
const [appPayload, canvasPayload] = await Promise.all([
|
||||
appResponse.json().catch(() => ({})),
|
||||
canvasResponse.json().catch(() => ({})),
|
||||
]);
|
||||
if (!appResponse.ok) {
|
||||
throw new Error(appPayload.error || "读取历史记录设置失败");
|
||||
}
|
||||
if (!canvasResponse.ok) {
|
||||
throw new Error(canvasPayload.error || "读取画布设置失败");
|
||||
}
|
||||
if (isActive) {
|
||||
setRetentionDays(String(appPayload.retentionDays ?? 7));
|
||||
setCanvasSettings(normalizeCanvasSettings(canvasPayload.item));
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
toast.error(error instanceof Error ? error.message : "读取设置失败");
|
||||
}
|
||||
} finally {
|
||||
if (isActive) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialSettings();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateCanvasSettings(settings: CanvasSettings) {
|
||||
setCanvasSettings(normalizeCanvasSettings(settings));
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const [appResponse, canvasResponse] = await Promise.all([
|
||||
fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ retentionDays }),
|
||||
}),
|
||||
fetch("/api/canvas/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(canvasSettings),
|
||||
}),
|
||||
]);
|
||||
const [appPayload, canvasPayload] = await Promise.all([
|
||||
appResponse.json().catch(() => ({})),
|
||||
canvasResponse.json().catch(() => ({})),
|
||||
]);
|
||||
if (!appResponse.ok) {
|
||||
throw new Error(appPayload.error || "保存历史记录设置失败");
|
||||
}
|
||||
if (!canvasResponse.ok) {
|
||||
throw new Error(canvasPayload.error || "保存画布设置失败");
|
||||
}
|
||||
|
||||
setRetentionDays(String(appPayload.retentionDays));
|
||||
setCanvasSettings(normalizeCanvasSettings(canvasPayload.item));
|
||||
setIsDirty(false);
|
||||
toast.success("设置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存设置失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-white">
|
||||
<Toaster richColors position="top-center" />
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-5 lg:px-8 lg:py-7">
|
||||
<div className="sticky top-0 z-20 -mx-4 mb-6 flex items-center justify-between gap-4 border-b border-zinc-200 bg-white/95 px-4 pb-4 backdrop-blur lg:-mx-8 lg:px-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-950">应用设置</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
配置生成渠道、模型、画布界面和历史记录。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={isLoading || isSaving || !isDirty || retentionDays === ""}
|
||||
type="button"
|
||||
onClick={() => void saveSettings()}
|
||||
>
|
||||
{isSaving ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{isSaving ? "保存中..." : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-6 md:grid-cols-[180px_minmax(0,1fr)] lg:gap-8">
|
||||
<aside className="min-w-0 md:border-r md:border-zinc-200 md:pr-4">
|
||||
<nav
|
||||
className="flex gap-1 overflow-x-auto border-b border-zinc-200 md:sticky md:top-24 md:grid md:overflow-visible md:border-b-0"
|
||||
aria-label="设置分类"
|
||||
>
|
||||
{settingsTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`shrink-0 border-b-2 px-3 py-2.5 text-left text-sm font-medium transition md:w-full md:rounded-md md:border-b-0 ${
|
||||
activeTab === tab.id
|
||||
? "border-zinc-950 text-zinc-950 md:bg-zinc-100"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-900 md:hover:bg-zinc-50"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0">
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-zinc-500">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
正在读取设置
|
||||
</div>
|
||||
) : activeTab === "channels" ? (
|
||||
<ChannelSettingsPanel
|
||||
settings={canvasSettings}
|
||||
onChange={updateCanvasSettings}
|
||||
/>
|
||||
) : activeTab === "interface" ? (
|
||||
<InterfaceSettings
|
||||
settings={canvasSettings}
|
||||
onChange={updateCanvasSettings}
|
||||
/>
|
||||
) : (
|
||||
<HistorySettings
|
||||
disabled={isSaving}
|
||||
retentionDays={retentionDays}
|
||||
onChange={(value) => {
|
||||
setRetentionDays(value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InterfaceSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: CanvasSettings;
|
||||
onChange: (settings: CanvasSettings) => void;
|
||||
}) {
|
||||
const patch = (value: Partial<CanvasSettings>) =>
|
||||
onChange(normalizeCanvasSettings({ ...settings, ...value }));
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-zinc-200 border-y border-zinc-200">
|
||||
<SettingToggle
|
||||
checked={settings.hideToolbarText}
|
||||
description="开启后底部操作栏仅显示图标,文字通过悬浮提示显示。"
|
||||
label="操作栏隐藏文字"
|
||||
onChange={(checked) => patch({ hideToolbarText: checked })}
|
||||
/>
|
||||
<SettingToggle
|
||||
checked={settings.showImageInfoBadge}
|
||||
description="开启后会在图片节点右下角显示分辨率和文件大小角标。"
|
||||
label="显示图片信息"
|
||||
onChange={(checked) => patch({ showImageInfoBadge: checked })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingToggle({
|
||||
checked,
|
||||
description,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
description: string;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-zinc-950">{label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{description}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistorySettings({
|
||||
disabled,
|
||||
retentionDays,
|
||||
onChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
retentionDays: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-xl space-y-2">
|
||||
<Label htmlFor="retention-days">历史记录保留天数</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
type="number"
|
||||
value={retentionDays}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-zinc-500">
|
||||
设置为 0 会在保存后立即清理历史记录和对应生成文件。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
@@ -49,7 +49,20 @@ export async function POST(request: Request) {
|
||||
|
||||
function normalizeBaseUrl(value: string | undefined) {
|
||||
if (!value) return "";
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
if (pathname === "" || pathname === "/") {
|
||||
url.pathname = "/v1";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
return trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { streamImageJobEvents } from "@/lib/server/services/image-job-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
export const maxDuration = 600;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return streamImageJobEvents(request);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@/lib/server/services/image-job-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
export const maxDuration = 600;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return submitImageJobRequest(request);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { HomeShell } from "@/components/direct/home-shell";
|
||||
|
||||
export default function CanvasProjectsPage() {
|
||||
return <HomeShell initialView="canvas" />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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 === "assets" ||
|
||||
candidate === "settings" ||
|
||||
candidate === "canvas"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
return "studio";
|
||||
}
|
||||
|
||||
export default async function Home(props: PageProps<"/">) {
|
||||
const searchParams = await props.searchParams;
|
||||
const view = normalizeView(searchParams.view);
|
||||
return <HomeShell key={`home-${view}`} initialView={view} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -970,6 +970,7 @@ function ConfigFields({
|
||||
channelId={selectedChannelId}
|
||||
model={metadata.model}
|
||||
settings={canvasSettings}
|
||||
useDefaultModel={metadata.useDefaultModel !== false}
|
||||
onChange={(next) => patch(next)}
|
||||
/>
|
||||
<SizeSelector
|
||||
@@ -1029,27 +1030,32 @@ function ConfigFields({
|
||||
type ModelChannelPatch = {
|
||||
channelId?: string;
|
||||
model?: string;
|
||||
useDefaultModel?: boolean;
|
||||
};
|
||||
|
||||
function ModelChannelFields({
|
||||
channelId,
|
||||
model,
|
||||
settings,
|
||||
useDefaultModel = false,
|
||||
onChange,
|
||||
}: {
|
||||
channelId: string;
|
||||
model: string;
|
||||
settings: CanvasSettings;
|
||||
useDefaultModel?: boolean;
|
||||
onChange: (next: ModelChannelPatch) => void;
|
||||
}) {
|
||||
const imageChannels = settings.channels.filter(
|
||||
(channel) => channel.kind === "image",
|
||||
);
|
||||
const selectedChannel =
|
||||
settings.channels.find((channel) => channel.id === channelId) ??
|
||||
settings.channels[0];
|
||||
imageChannels.find((channel) => channel.id === channelId) ??
|
||||
imageChannels[0];
|
||||
const channelModels = selectedChannel?.models ?? [];
|
||||
const resolvedModel =
|
||||
model ||
|
||||
settings.defaultModels.image ||
|
||||
settings.modelPreferences.image[0] ||
|
||||
(useDefaultModel ? selectedChannel?.defaultModel : model) ||
|
||||
selectedChannel?.defaultModel ||
|
||||
channelModels[0] ||
|
||||
"";
|
||||
const modelOptions = resolvedModel && !channelModels.includes(resolvedModel)
|
||||
@@ -1066,7 +1072,7 @@ function ModelChannelFields({
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
|
||||
节点级
|
||||
{useDefaultModel ? "默认" : "节点级"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
@@ -1075,20 +1081,23 @@ function ModelChannelFields({
|
||||
value={selectedChannel?.id ?? ""}
|
||||
onValueChange={(nextChannelId) => {
|
||||
const nextChannel =
|
||||
settings.channels.find((channel) => channel.id === nextChannelId) ??
|
||||
settings.channels[0];
|
||||
imageChannels.find((channel) => channel.id === nextChannelId) ??
|
||||
imageChannels[0];
|
||||
const nextModels = nextChannel?.models ?? [];
|
||||
const nextModel = nextModels.includes(resolvedModel)
|
||||
? resolvedModel
|
||||
: nextModels[0] || resolvedModel;
|
||||
onChange({ channelId: nextChannelId, model: nextModel });
|
||||
const nextModel =
|
||||
nextChannel?.defaultModel || nextModels[0] || resolvedModel;
|
||||
onChange({
|
||||
channelId: nextChannelId,
|
||||
model: nextModel,
|
||||
useDefaultModel: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue placeholder="选择渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settings.channels.map((channel) => (
|
||||
{imageChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
@@ -1100,7 +1109,9 @@ function ModelChannelFields({
|
||||
{modelOptions.length ? (
|
||||
<Select
|
||||
value={resolvedModel}
|
||||
onValueChange={(nextModel) => onChange({ model: nextModel })}
|
||||
onValueChange={(nextModel) =>
|
||||
onChange({ model: nextModel, useDefaultModel: false })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
@@ -1118,7 +1129,9 @@ function ModelChannelFields({
|
||||
className="h-10 rounded-xl border-zinc-200 bg-white"
|
||||
placeholder="先在渠道配置中添加模型"
|
||||
value={resolvedModel}
|
||||
onChange={(event) => onChange({ model: event.target.value })}
|
||||
onChange={(event) =>
|
||||
onChange({ model: event.target.value, useDefaultModel: false })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
type EditingProjectState = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
} | null;
|
||||
|
||||
export function CanvasProjectsClient() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
async function loadProjects() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
setProjects(Array.isArray(payload.items) ? payload.items : []);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "加载画布失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "未命名画布" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
const id = payload?.item?.id;
|
||||
if (id) {
|
||||
router.push(`/canvas/${id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "新建画布失败");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProjectEdit() {
|
||||
if (!editingProject || isSavingEdit) return;
|
||||
const title = editingProject.title.trim();
|
||||
if (!title) {
|
||||
toast.error("标题不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: editingProject.description,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.item) {
|
||||
throw new Error(payload.error || "保存画布信息失败");
|
||||
}
|
||||
const updatedProject = payload.item as CanvasProjectListItem;
|
||||
setProjects((current) =>
|
||||
current.map((project) =>
|
||||
project.id === updatedProject.id
|
||||
? {
|
||||
...project,
|
||||
title: updatedProject.title,
|
||||
description: updatedProject.description,
|
||||
updatedAt: updatedProject.updatedAt,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
);
|
||||
setEditingProject(null);
|
||||
toast.success("画布信息已更新");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(project: CanvasProjectListItem) {
|
||||
setDeletingProjectId(project.id);
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/projects/${project.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "删除画布失败");
|
||||
}
|
||||
setProjects((current) => current.filter((item) => item.id !== project.id));
|
||||
toast.success("画布已删除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "删除画布失败");
|
||||
} finally {
|
||||
setDeletingProjectId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
|
||||
<Toaster richColors position="top-center" />
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">无限画布</h1>
|
||||
<p className="text-sm text-zinc-500">一画布一项目,支持节点流式生成。</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => void loadProjects()}>
|
||||
<RefreshCcw />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isCreating} onClick={() => void createProject()}>
|
||||
<Plus />
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-6 text-sm text-zinc-500">
|
||||
正在加载画布...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
className="min-w-0 flex-1"
|
||||
href={`/canvas/${project.id}`}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{project.title}</div>
|
||||
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
|
||||
{project.description || "暂无说明"}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-500">
|
||||
{project.nodeCount} 个节点 · 更新于{" "}
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<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={() =>
|
||||
setEditingProject({
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
})
|
||||
}
|
||||
title="编辑画布信息"
|
||||
>
|
||||
<Pencil 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>
|
||||
))}
|
||||
{!projects.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
||||
还没有画布,先新建一个。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProjectEditDialog
|
||||
project={editingProject}
|
||||
isSaving={isSavingEdit}
|
||||
onChange={setEditingProject}
|
||||
onClose={() => {
|
||||
if (!isSavingEdit) setEditingProject(null);
|
||||
}}
|
||||
onSave={() => void saveProjectEdit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectEditDialog({
|
||||
project,
|
||||
isSaving,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
project: EditingProjectState;
|
||||
isSaving: boolean;
|
||||
onChange: (project: EditingProjectState) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
|
||||
<div className="flex items-center justify-between gap-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"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="mt-5 block text-sm font-medium text-zinc-700">
|
||||
标题
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
|
||||
disabled={isSaving}
|
||||
maxLength={80}
|
||||
value={project.title}
|
||||
onChange={(event) =>
|
||||
onChange({ ...project, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-4 block text-sm font-medium text-zinc-700">
|
||||
说明
|
||||
<textarea
|
||||
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
|
||||
disabled={isSaving}
|
||||
maxLength={300}
|
||||
placeholder="给这个画布补充用途、阶段或备注"
|
||||
value={project.description}
|
||||
onChange={(event) =>
|
||||
onChange({ ...project, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={isSaving} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
Keyboard,
|
||||
Loader2,
|
||||
Menu,
|
||||
MessageSquarePlus,
|
||||
Minus,
|
||||
Plus,
|
||||
Redo2,
|
||||
BookOpenText,
|
||||
Save,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SquareDashedMousePointer,
|
||||
LayoutGrid,
|
||||
@@ -31,9 +31,14 @@ import {
|
||||
import {Button} from "@/components/ui/button";
|
||||
import type {CanvasBackgroundMode, CanvasNodeType} from "@/lib/canvas/types";
|
||||
|
||||
type CanvasSaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
|
||||
|
||||
type CanvasToolbarProps = {
|
||||
title: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveStatus: CanvasSaveStatus;
|
||||
lastSaveError: string | null;
|
||||
isGenerating: boolean;
|
||||
lastSavedAt: Date | null;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
@@ -48,6 +53,7 @@ type CanvasToolbarProps = {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onGenerate: () => void;
|
||||
onOpenPromptComposer: () => void;
|
||||
onSave: () => void;
|
||||
onUploadMaterial: () => void;
|
||||
onCopyJson: () => void;
|
||||
@@ -65,12 +71,14 @@ type CanvasToolbarProps = {
|
||||
onToggleLibrary: () => void;
|
||||
onToggleSelectionMode: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export function CanvasToolbar({
|
||||
title,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveStatus,
|
||||
lastSaveError,
|
||||
isGenerating,
|
||||
lastSavedAt,
|
||||
backgroundMode,
|
||||
@@ -85,6 +93,7 @@ export function CanvasToolbar({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onGenerate,
|
||||
onOpenPromptComposer,
|
||||
onSave,
|
||||
onUploadMaterial,
|
||||
onCopyJson,
|
||||
@@ -102,7 +111,6 @@ export function CanvasToolbar({
|
||||
onToggleLibrary,
|
||||
onToggleSelectionMode,
|
||||
onShowShortcuts,
|
||||
onOpenSettings,
|
||||
}: CanvasToolbarProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -189,19 +197,14 @@ export function CanvasToolbar({
|
||||
<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
|
||||
isDirty={isDirty}
|
||||
isGenerating={isGenerating}
|
||||
isSaving={isSaving}
|
||||
saveStatus={saveStatus}
|
||||
lastSaveError={lastSaveError}
|
||||
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"
|
||||
@@ -315,6 +318,12 @@ export function CanvasToolbar({
|
||||
onClick={onRedo}
|
||||
/>
|
||||
<DockDivider/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<MessageSquarePlus className="size-4"/>}
|
||||
label="对话生成"
|
||||
onClick={onOpenPromptComposer}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Type className="size-4"/>}
|
||||
@@ -427,38 +436,63 @@ function MenuDivider() {
|
||||
}
|
||||
|
||||
function SaveStatusButton({
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveStatus,
|
||||
lastSaveError,
|
||||
isGenerating,
|
||||
lastSavedAt,
|
||||
onSave,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveStatus: CanvasSaveStatus;
|
||||
lastSaveError: string | null;
|
||||
isGenerating: boolean;
|
||||
lastSavedAt: Date | null;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "保存";
|
||||
const savedTime = lastSavedAt
|
||||
? lastSavedAt.toLocaleTimeString("zh-CN", {
|
||||
const savedAtText = lastSavedAt
|
||||
? `已同步 ${lastSavedAt.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "--:--";
|
||||
const title = isSaving ? "正在保存画布" : `保存画布,上次保存 ${savedTime}`;
|
||||
})}`
|
||||
: "尚未保存";
|
||||
const label = saveStatus === "saving" || isSaving ? "保存中" : "保存";
|
||||
const statusText =
|
||||
saveStatus === "saving" || isSaving
|
||||
? "保存中"
|
||||
: saveStatus === "error"
|
||||
? "保存失败"
|
||||
: isDirty || saveStatus === "dirty"
|
||||
? isGenerating
|
||||
? "生成中 · 未保存"
|
||||
: "未保存"
|
||||
: savedAtText;
|
||||
const title =
|
||||
saveStatus === "error"
|
||||
? lastSaveError || "保存失败,点击重试"
|
||||
: saveStatus === "saving" || isSaving
|
||||
? "正在保存画布"
|
||||
: isDirty || saveStatus === "dirty"
|
||||
? "保存当前画布更改"
|
||||
: `画布${savedAtText}`;
|
||||
|
||||
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}
|
||||
disabled={isSaving || (!isDirty && saveStatus !== "error")}
|
||||
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 className={`mt-0.5 text-[9px] font-medium ${saveStatus === "error" ? "text-red-500" : "text-zinc-400"}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
"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));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Aperture,
|
||||
Images,
|
||||
ImageIcon,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCcw,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
} 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" | "assets" | "settings" | "canvas";
|
||||
|
||||
export function HomeShell({
|
||||
initialView = "studio",
|
||||
}: {
|
||||
initialView?: ActiveView;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [activeView, setActiveView] = useState<ActiveView>(initialView);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const routeView: ActiveView = pathname.startsWith("/canvas") ? "canvas" : activeView;
|
||||
|
||||
function handleViewChange(view: ActiveView) {
|
||||
if (view === "canvas") {
|
||||
router.push("/canvas", { scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveView(view);
|
||||
const href = view === "studio" ? "/" : `/?view=${view}`;
|
||||
router.push(href, { scroll: false });
|
||||
}
|
||||
|
||||
const pageMeta = {
|
||||
studio: {
|
||||
title: "图片生成应用",
|
||||
description: "直接模式:文生图、图生图、历史查看",
|
||||
},
|
||||
history: {
|
||||
title: "作品记录",
|
||||
description: "查看直接模式生成的图片与任务参数",
|
||||
},
|
||||
assets: {
|
||||
title: "资产管理",
|
||||
description: "按分类管理所有生成内容、备注和删除",
|
||||
},
|
||||
settings: {
|
||||
title: "模型设置",
|
||||
description: "配置默认模型、输出尺寸、质量和编辑保护策略",
|
||||
},
|
||||
canvas: {
|
||||
title: "无限画布",
|
||||
description: "一画布一项目,支持节点流式生成",
|
||||
},
|
||||
}[routeView];
|
||||
|
||||
return (
|
||||
<main className="h-screen w-full overflow-hidden bg-zinc-100 text-zinc-950">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "grid min-h-screen overflow-x-hidden lg:grid-cols-[72px_minmax(0,1fr)]"
|
||||
: "grid min-h-screen overflow-x-hidden lg:grid-cols-[248px_minmax(0,1fr)]"
|
||||
}
|
||||
>
|
||||
<aside className="hidden h-full overflow-hidden border-r border-zinc-200 bg-white lg:block">
|
||||
<div
|
||||
className={
|
||||
sidebarCollapsed
|
||||
? "flex h-14 items-center justify-center border-b border-zinc-200 px-3"
|
||||
: "flex h-14 items-center gap-2 border-b border-zinc-200 px-4"
|
||||
}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-zinc-950 text-white">
|
||||
<Aperture className="size-4" />
|
||||
</div>
|
||||
<div className={sidebarCollapsed ? "hidden" : ""}>
|
||||
<div className="text-sm font-semibold">Image Studio</div>
|
||||
<div className="text-xs text-zinc-500">direct mode</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex h-[calc(100%-56px)] flex-col px-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "studio" ? "secondary" : "ghost"}
|
||||
title="生成工作台"
|
||||
onClick={() => handleViewChange("studio")}
|
||||
>
|
||||
<Sparkles />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>生成工作台</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "history" ? "secondary" : "ghost"}
|
||||
title="作品记录"
|
||||
onClick={() => handleViewChange("history")}
|
||||
>
|
||||
<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"}
|
||||
title="模型设置"
|
||||
onClick={() => handleViewChange("settings")}
|
||||
>
|
||||
<Settings2 />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>模型设置</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-zinc-200 pt-5">
|
||||
<Button
|
||||
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
|
||||
variant={routeView === "canvas" ? "secondary" : "ghost"}
|
||||
title="无限画布"
|
||||
onClick={() => handleViewChange("canvas")}
|
||||
>
|
||||
<Workflow />
|
||||
<span className={sidebarCollapsed ? "sr-only" : ""}>无限画布</span>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex h-screen min-w-0 flex-col overflow-hidden">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 bg-white px-4 lg:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={sidebarCollapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
>
|
||||
<PanelLeft className="lg:hidden" />
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftOpen className="hidden lg:block" />
|
||||
) : (
|
||||
<PanelLeftClose className="hidden lg:block" />
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">{pageMeta.title}</h1>
|
||||
<p className="text-xs text-zinc-500">{pageMeta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{routeView === "canvas" ? "canvas" : "direct"}</Badge>
|
||||
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{routeView === "canvas" ? (
|
||||
<CanvasProjectsClient />
|
||||
) : routeView === "assets" ? (
|
||||
<AssetManager />
|
||||
) : (
|
||||
<DirectStudio activeView={routeView} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function resolveCanvasGenerationInput(
|
||||
? formatConnectedPrompts(connectedPrompts)
|
||||
: (resolvedMentions?.prompt ?? "");
|
||||
const negativePrompt = resolveNegativePrompt(
|
||||
promptNode,
|
||||
promptNode ?? undefined,
|
||||
resolvedMentions?.referencedNodeIds ? scopedNodes : nodes,
|
||||
scopedConnections,
|
||||
);
|
||||
@@ -83,8 +83,16 @@ export function resolveCanvasGenerationInput(
|
||||
}
|
||||
|
||||
const configNode =
|
||||
scopedNodes.find((node) => node.type === "config") ??
|
||||
nodes.find((node) => node.type === "config");
|
||||
findConnectedConfigNode(scopedNodes, scopedConnections, [
|
||||
selectedNode?.id,
|
||||
promptNode.id,
|
||||
connectedPromptNode?.id,
|
||||
]) ??
|
||||
findConnectedConfigNode(scopedNodes, scopedConnections, [
|
||||
...connectedPrompts.map((source) => source.nodeId),
|
||||
...imageNodesFromConnections(scopedNodes, scopedConnections).map((node) => node.id),
|
||||
]);
|
||||
const selectedImageConfig = getImageGenerationConfig(selectedNode ?? undefined);
|
||||
const imageNodes = collectReferencedImageNodes(
|
||||
scopedNodes,
|
||||
resolvedMentions?.referencedNodeIds ?? [],
|
||||
@@ -100,11 +108,52 @@ export function resolveCanvasGenerationInput(
|
||||
config: {
|
||||
...defaultCanvasConfig,
|
||||
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
|
||||
...selectedImageConfig,
|
||||
...getImageGenerationConfig(promptNode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findConnectedConfigNode(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
targetIds: Array<string | undefined | null>,
|
||||
) {
|
||||
const targetIdSet = new Set(targetIds.filter(isString));
|
||||
if (!targetIdSet.size) return undefined;
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
for (const connection of connections) {
|
||||
const from = nodeById.get(connection.fromNodeId);
|
||||
const to = nodeById.get(connection.toNodeId);
|
||||
if (from?.type === "config" && targetIdSet.has(to?.id ?? "")) {
|
||||
return from;
|
||||
}
|
||||
if (to?.type === "config" && targetIdSet.has(from?.id ?? "")) {
|
||||
return to;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function imageNodesFromConnections(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
return connections
|
||||
.flatMap((connection) => [
|
||||
nodeById.get(connection.fromNodeId),
|
||||
nodeById.get(connection.toNodeId),
|
||||
])
|
||||
.filter(isImageWithUrl);
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function resolveNegativePrompt(
|
||||
promptNode: CanvasNode | undefined,
|
||||
nodes: CanvasNode[],
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CanvasPosition,
|
||||
} from "@/lib/canvas/types";
|
||||
import type { HistoryItem } from "@/lib/image-workflow";
|
||||
import { defaultCanvasSettings } from "@/lib/canvas/settings";
|
||||
|
||||
export const DEFAULT_NODE_WIDTH = 280;
|
||||
export const DEFAULT_NODE_HEIGHT = 170;
|
||||
@@ -14,7 +15,8 @@ export const DEFAULT_IMAGE_HEIGHT = 240;
|
||||
|
||||
export const defaultCanvasConfig: CanvasConfigNodeMetadata = {
|
||||
channelId: "",
|
||||
model: "gpt-image-2-2k",
|
||||
model: defaultCanvasSettings.defaultModels.image,
|
||||
useDefaultModel: true,
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
outputFormat: "png",
|
||||
|
||||
@@ -129,6 +129,7 @@ export function resolvePromptMentions(
|
||||
mentions: PromptMentionSource[],
|
||||
) {
|
||||
const explicitlyReferencedNodeIds: string[] = [];
|
||||
const usedMentions: PromptMentionSource[] = [];
|
||||
let resolvedPrompt = prompt;
|
||||
|
||||
for (const mention of mentions) {
|
||||
@@ -140,6 +141,7 @@ export function resolvePromptMentions(
|
||||
const usedAlias = resolvedPrompt.includes(mention.alias);
|
||||
if (!usedMarkdownToken && !usedAlias) continue;
|
||||
explicitlyReferencedNodeIds.push(mention.nodeId);
|
||||
usedMentions.push(mention);
|
||||
resolvedPrompt = resolvedPrompt.split(mention.token).join(replacement);
|
||||
resolvedPrompt = resolvedPrompt.split(mention.alias).join(replacement);
|
||||
}
|
||||
|
||||
+90
-74
@@ -1,9 +1,11 @@
|
||||
export type CanvasModelChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: CanvasModelKind;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
export type CanvasModelKind = "image" | "video" | "text" | "audio";
|
||||
@@ -25,9 +27,11 @@ export type CanvasSettings = {
|
||||
export const defaultCanvasChannel: CanvasModelChannel = {
|
||||
id: "default",
|
||||
name: "默认渠道",
|
||||
kind: "image",
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "",
|
||||
models: ["gpt-image-2"],
|
||||
models: ["gpt-image-2-2k"],
|
||||
defaultModel: "gpt-image-2-2k",
|
||||
};
|
||||
|
||||
export const defaultCanvasSettings: CanvasSettings = {
|
||||
@@ -71,6 +75,7 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
|
||||
? candidate.baseUrl
|
||||
: defaultCanvasChannel.baseUrl,
|
||||
models: [legacyModel],
|
||||
defaultModel: legacyModel,
|
||||
};
|
||||
return {
|
||||
hideToolbarText: Boolean(candidate.hideToolbarText),
|
||||
@@ -92,12 +97,26 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
|
||||
channels: [legacyChannel],
|
||||
};
|
||||
}
|
||||
const rawChannels = candidate.channels;
|
||||
|
||||
const channels = candidate.channels
|
||||
const candidateSelectedModel =
|
||||
typeof candidate.selectedModel === "string" && candidate.selectedModel.trim()
|
||||
? candidate.selectedModel.trim()
|
||||
: "";
|
||||
const candidateDefaultModels =
|
||||
candidate.defaultModels && typeof candidate.defaultModels === "object"
|
||||
? (candidate.defaultModels as Partial<CanvasDefaultModels>)
|
||||
: {};
|
||||
const preferredImageModel =
|
||||
(typeof candidateDefaultModels.image === "string" &&
|
||||
candidateDefaultModels.image.trim()) ||
|
||||
candidateSelectedModel;
|
||||
|
||||
const channels = rawChannels
|
||||
.map((channel) => normalizeCanvasChannel(channel))
|
||||
.filter((channel): channel is CanvasModelChannel => Boolean(channel));
|
||||
const normalizedChannels = channels.length ? channels : [defaultCanvasChannel];
|
||||
const selectedChannelId =
|
||||
let selectedChannelId =
|
||||
typeof candidate.selectedChannelId === "string" &&
|
||||
normalizedChannels.some((channel) => channel.id === candidate.selectedChannelId)
|
||||
? candidate.selectedChannelId
|
||||
@@ -105,89 +124,74 @@ export function normalizeCanvasSettings(value: unknown): CanvasSettings {
|
||||
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,
|
||||
if (
|
||||
preferredImageModel &&
|
||||
selectedChannel.kind === "image" &&
|
||||
!selectedChannel.models.includes(preferredImageModel)
|
||||
) {
|
||||
selectedChannel.models = [preferredImageModel, ...selectedChannel.models];
|
||||
}
|
||||
const kinds: CanvasModelKind[] = ["image", "video", "text", "audio"];
|
||||
const modelPreferences = Object.fromEntries(
|
||||
kinds.map((kind) => [
|
||||
kind,
|
||||
Array.from(
|
||||
new Set(
|
||||
normalizedChannels
|
||||
.filter((channel) => channel.kind === kind)
|
||||
.flatMap((channel) => channel.models),
|
||||
),
|
||||
),
|
||||
]),
|
||||
) as CanvasModelPreferences;
|
||||
const defaultModels = Object.fromEntries(
|
||||
kinds.map((kind) => {
|
||||
const hasChannelDefault = rawChannels.some((value) => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const channel = value as Partial<CanvasModelChannel>;
|
||||
const channelKind = isCanvasModelKind(channel.kind) ? channel.kind : "image";
|
||||
return channelKind === kind && typeof channel.defaultModel === "string";
|
||||
});
|
||||
const legacyDefault = hasChannelDefault
|
||||
? ""
|
||||
: candidateDefaultModels[kind]?.trim() || "";
|
||||
const channelsForKind = normalizedChannels.filter(
|
||||
(channel) => channel.kind === kind,
|
||||
);
|
||||
const defaultChannel =
|
||||
channelsForKind.find(
|
||||
(channel) => legacyDefault && channel.models.includes(legacyDefault),
|
||||
) ?? channelsForKind.find((channel) => channel.defaultModel) ?? channelsForKind[0];
|
||||
const defaultModel =
|
||||
(defaultChannel?.models.includes(legacyDefault) ? legacyDefault : "") ||
|
||||
(defaultChannel?.models.includes(defaultChannel.defaultModel)
|
||||
? defaultChannel.defaultModel
|
||||
: "") ||
|
||||
defaultChannel?.models[0] ||
|
||||
"";
|
||||
if (defaultChannel) defaultChannel.defaultModel = defaultModel;
|
||||
return [kind, defaultModel];
|
||||
}),
|
||||
) as CanvasDefaultModels;
|
||||
const selectedImageChannel =
|
||||
normalizedChannels.find(
|
||||
(channel) =>
|
||||
channel.kind === "image" && channel.id === selectedChannelId,
|
||||
) ?? normalizedChannels.find((channel) => channel.kind === "image");
|
||||
if (selectedImageChannel) selectedChannelId = selectedImageChannel.id;
|
||||
const selectedModel = selectedImageChannel?.defaultModel || defaultModels.image;
|
||||
|
||||
return {
|
||||
hideToolbarText: Boolean(candidate.hideToolbarText),
|
||||
showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
|
||||
selectedChannelId,
|
||||
selectedModel: defaultModels.image || selectedModel,
|
||||
selectedModel: selectedModel || defaultModels.image,
|
||||
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>;
|
||||
@@ -201,15 +205,27 @@ function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null {
|
||||
? candidate.baseUrl.trim()
|
||||
: "";
|
||||
const apiKey = typeof candidate.apiKey === "string" ? candidate.apiKey : "";
|
||||
const kind = isCanvasModelKind(candidate.kind) ? candidate.kind : "image";
|
||||
const models = Array.isArray(candidate.models)
|
||||
? candidate.models.filter((model): model is string => typeof model === "string" && Boolean(model.trim()))
|
||||
: [];
|
||||
const defaultModel =
|
||||
typeof candidate.defaultModel === "string" &&
|
||||
models.includes(candidate.defaultModel)
|
||||
? candidate.defaultModel
|
||||
: models[0] ?? "";
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models,
|
||||
defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
function isCanvasModelKind(value: unknown): value is CanvasModelKind {
|
||||
return ["image", "video", "text", "audio"].includes(String(value));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type CanvasPromptNodeMetadata = {
|
||||
export type CanvasConfigNodeMetadata = {
|
||||
channelId?: string;
|
||||
model: string;
|
||||
useDefaultModel?: boolean;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
@@ -263,6 +264,9 @@ function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | nul
|
||||
return {
|
||||
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
|
||||
model: candidate.model,
|
||||
...(typeof candidate.useDefaultModel === "boolean"
|
||||
? { useDefaultModel: candidate.useDefaultModel }
|
||||
: {}),
|
||||
size: candidate.size,
|
||||
quality: candidate.quality,
|
||||
outputFormat: candidate.outputFormat,
|
||||
|
||||
@@ -202,9 +202,13 @@ export function buildGenerationError(
|
||||
getStringField(payload, "message") ||
|
||||
response.statusText ||
|
||||
"生成失败";
|
||||
const code = getStringField(payload, "code");
|
||||
|
||||
return {
|
||||
title: `生成失败(HTTP ${response.status})`,
|
||||
title:
|
||||
code === "PROHIBITED_PROMPT"
|
||||
? "提示词包含违禁词"
|
||||
: `生成失败(HTTP ${response.status})`,
|
||||
message,
|
||||
status: response.status,
|
||||
requestId,
|
||||
@@ -214,7 +218,7 @@ export function buildGenerationError(
|
||||
Math.round(performance.now() - requestStartedAt),
|
||||
upstreamStatus: getNumberField(payload, "upstreamStatus"),
|
||||
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
|
||||
code: getStringField(payload, "code"),
|
||||
code,
|
||||
type: getStringField(payload, "type"),
|
||||
details: payload.details,
|
||||
rawBody: getStringField(payload, "rawBody"),
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
const prohibitedWords = `裸聊
|
||||
自杀
|
||||
杀人
|
||||
贩毒
|
||||
枪支
|
||||
炸药
|
||||
分尸
|
||||
色情
|
||||
淫秽
|
||||
裸体
|
||||
做爱
|
||||
性交
|
||||
口交
|
||||
抽插
|
||||
肛交
|
||||
射精
|
||||
迷奸
|
||||
幼女
|
||||
援交
|
||||
卖淫
|
||||
裸照
|
||||
nude
|
||||
naked
|
||||
porn
|
||||
xxx
|
||||
sex(ual)
|
||||
erotic
|
||||
incest
|
||||
underage
|
||||
bestiality
|
||||
sexual
|
||||
发情
|
||||
高潮
|
||||
阴道
|
||||
阴茎
|
||||
乳房
|
||||
屁股
|
||||
淫水
|
||||
催情
|
||||
乱伦
|
||||
捆绑
|
||||
虐待
|
||||
兽交
|
||||
轮奸
|
||||
鸡巴
|
||||
操你
|
||||
干你
|
||||
骚货
|
||||
贱货
|
||||
荡妇
|
||||
婊子
|
||||
约炮
|
||||
直播做爱
|
||||
成人影片
|
||||
成人视频
|
||||
成人电影
|
||||
皇色
|
||||
簧色
|
||||
嗨粉
|
||||
吸白
|
||||
溜冰
|
||||
啪啪啪
|
||||
18禁
|
||||
工口
|
||||
里番
|
||||
nsfw
|
||||
fuck
|
||||
shit
|
||||
dick
|
||||
pussy
|
||||
bitch
|
||||
whore
|
||||
slut
|
||||
molest
|
||||
hentai
|
||||
bondage
|
||||
bdsm
|
||||
sperm
|
||||
ejaculate
|
||||
碎尸
|
||||
肢解
|
||||
吃人
|
||||
活埋
|
||||
凌迟
|
||||
割喉
|
||||
斩首
|
||||
焚烧
|
||||
torture
|
||||
murder
|
||||
decapitate
|
||||
毒品
|
||||
冰毒
|
||||
海洛因
|
||||
大麻
|
||||
可卡因
|
||||
制毒
|
||||
吸毒
|
||||
赌博
|
||||
赌场
|
||||
开赌
|
||||
诈骗
|
||||
骗钱
|
||||
钓鱼
|
||||
盗号
|
||||
黑客
|
||||
木马
|
||||
病毒
|
||||
入侵
|
||||
破解
|
||||
盗版
|
||||
steal credentials
|
||||
dump passwords
|
||||
extract tokens
|
||||
exfiltrate
|
||||
ransomware
|
||||
trojan
|
||||
backdoor
|
||||
botnet
|
||||
keylogger
|
||||
infostealer
|
||||
免杀
|
||||
绕过杀软
|
||||
绕过edr
|
||||
bypass av
|
||||
bypass defender
|
||||
bypass edr
|
||||
reverse shell
|
||||
bind shell
|
||||
meterpreter
|
||||
web shell
|
||||
反弹shell
|
||||
提权
|
||||
privilege escalation
|
||||
privesc
|
||||
root exploit
|
||||
persistence
|
||||
registry run key
|
||||
exploit payload
|
||||
shellcode
|
||||
rop chain
|
||||
buffer overflow
|
||||
zero-day
|
||||
0day
|
||||
漏洞利用
|
||||
攻击载荷
|
||||
proof of concept
|
||||
注册机
|
||||
keygen
|
||||
crack license
|
||||
license bypass
|
||||
绕过授权
|
||||
破解授权
|
||||
序列号生成
|
||||
数据窃取
|
||||
data exfiltration
|
||||
steal data
|
||||
数据外泄
|
||||
lsass dump
|
||||
credential dumping
|
||||
hashdump
|
||||
sam dump
|
||||
reverse engineering
|
||||
ida pro
|
||||
ghidra
|
||||
x64dbg
|
||||
ollydbg
|
||||
frida
|
||||
frida hook
|
||||
反编译
|
||||
脱壳
|
||||
逆向
|
||||
容器逃逸
|
||||
container escape
|
||||
docker breakout
|
||||
kubernetes escape
|
||||
sandbox escape
|
||||
zero-click
|
||||
zero click
|
||||
supply chain attack
|
||||
依赖投毒
|
||||
malicious package
|
||||
trojanized package
|
||||
ddos
|
||||
拒绝服务攻击
|
||||
dos attack
|
||||
cryptojacking
|
||||
非法挖矿
|
||||
挖矿劫持
|
||||
process injection
|
||||
dll injection
|
||||
fileless malware
|
||||
log tampering
|
||||
anti-forensics
|
||||
command injection
|
||||
sql injection
|
||||
xss payload
|
||||
path traversal
|
||||
ssrf
|
||||
xxe
|
||||
lateral movement
|
||||
横向移动
|
||||
token theft
|
||||
jwt hijacking
|
||||
api key leak
|
||||
cloud credential
|
||||
aws key leak
|
||||
azure token
|
||||
hardware implant
|
||||
usb rubber ducky
|
||||
恶意usb
|
||||
firmware backdoor
|
||||
bios rootkit
|
||||
kernel exploit
|
||||
内核提权
|
||||
phishing
|
||||
钓鱼页面
|
||||
credential harvesting
|
||||
fake login
|
||||
渗透`
|
||||
.split("\n")
|
||||
.map(normalizeForMatching)
|
||||
.filter(Boolean);
|
||||
|
||||
export function findProhibitedWords(...prompts: Array<string | undefined>) {
|
||||
const normalizedPrompt = prompts
|
||||
.filter((prompt): prompt is string => Boolean(prompt))
|
||||
.map(normalizeForMatching)
|
||||
.join("\n");
|
||||
|
||||
if (!normalizedPrompt) return [];
|
||||
|
||||
return prohibitedWords.filter((word) => normalizedPrompt.includes(word));
|
||||
}
|
||||
|
||||
function normalizeForMatching(value: string) {
|
||||
return value.normalize("NFKC").toLocaleLowerCase("en-US").trim();
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
persistBase64Image,
|
||||
readPersistedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
import { findProhibitedWords } from "@/lib/server/prompt-moderation";
|
||||
|
||||
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.";
|
||||
const openAITimeoutMs = 270_000;
|
||||
const defaultOpenAITimeoutMs = 600_000;
|
||||
const openAITimeoutMs = getOpenAITimeoutMs();
|
||||
const openAIMaxRetries = 0;
|
||||
const jobRetentionMs = 60 * 60 * 1000;
|
||||
|
||||
@@ -42,6 +44,8 @@ type ImageJobInput = {
|
||||
quality: Quality;
|
||||
outputFormat: OutputFormat;
|
||||
preserveIdentity: boolean;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
channelBaseUrl?: string;
|
||||
channelApiKey?: string;
|
||||
images: UploadedFile[];
|
||||
@@ -120,8 +124,34 @@ export async function submitImageJobRequest(request: Request) {
|
||||
phase = "parse-form";
|
||||
const formData = await request.formData();
|
||||
const input = await parseImageJobInput(formData);
|
||||
|
||||
phase = "validate-prompt";
|
||||
const matchedProhibitedWords = findProhibitedWords(
|
||||
input.prompt,
|
||||
input.negativePrompt,
|
||||
);
|
||||
if (matchedProhibitedWords.length > 0) {
|
||||
log("error", "prohibited words found in prompt", {
|
||||
matchedWordCount: matchedProhibitedWords.length,
|
||||
});
|
||||
return jsonWithRequestId(
|
||||
{
|
||||
error: `提示词中存在违禁词:${matchedProhibitedWords.join("、")},请修改后重试。`,
|
||||
code: "PROHIBITED_PROMPT",
|
||||
details: { prohibitedWords: matchedProhibitedWords },
|
||||
requestId,
|
||||
phase,
|
||||
durationMs: elapsedMs(startedAt),
|
||||
},
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = input.channelApiKey || process.env.OPENAI_API_KEY;
|
||||
const baseURL = input.channelBaseUrl || process.env.OPENAI_BASE_URL;
|
||||
const baseURL = normalizeOpenAIBaseUrl(
|
||||
input.channelBaseUrl || process.env.OPENAI_BASE_URL || "",
|
||||
);
|
||||
|
||||
if (!apiKey) {
|
||||
phase = "validate-env";
|
||||
@@ -145,6 +175,8 @@ export async function submitImageJobRequest(request: Request) {
|
||||
quality: input.quality,
|
||||
outputFormat: input.outputFormat,
|
||||
preserveIdentity: input.preserveIdentity,
|
||||
channelId: input.channelId,
|
||||
channelName: input.channelName,
|
||||
promptChars: input.prompt.length,
|
||||
promptPreview: preview(input.prompt),
|
||||
negativePromptChars: input.negativePrompt?.length ?? 0,
|
||||
@@ -601,13 +633,15 @@ async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||
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"),
|
||||
model: getString(formData, "model"),
|
||||
size: getString(formData, "size", "auto"),
|
||||
quality: normalizeQuality(getString(formData, "quality", "high")),
|
||||
outputFormat: normalizeOutputFormat(
|
||||
getString(formData, "outputFormat", "png"),
|
||||
),
|
||||
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
|
||||
channelId: getString(formData, "channelId") || undefined,
|
||||
channelName: getString(formData, "channelName") || undefined,
|
||||
channelBaseUrl: normalizeOptionalBaseUrl(getString(formData, "channelBaseUrl")),
|
||||
channelApiKey: getString(formData, "channelApiKey"),
|
||||
images: [...uploadedImages, ...referencedImages],
|
||||
@@ -624,6 +658,10 @@ function validateImageJobInput(input: ImageJobInput) {
|
||||
return "Prompt is required";
|
||||
}
|
||||
|
||||
if (!input.model.trim()) {
|
||||
return "Model is required";
|
||||
}
|
||||
|
||||
if (input.mode === "edit" && input.images.length === 0) {
|
||||
return "Edit mode requires an uploaded image";
|
||||
}
|
||||
@@ -822,6 +860,23 @@ function normalizeOptionalBaseUrl(value: string) {
|
||||
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
||||
}
|
||||
|
||||
function normalizeOpenAIBaseUrl(value: string) {
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
if (pathname === "" || pathname === "/") {
|
||||
url.pathname = "/v1";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
return trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuality(value: string): Quality {
|
||||
return ["low", "medium", "high", "auto"].includes(value)
|
||||
? (value as Quality)
|
||||
@@ -914,9 +969,14 @@ function serializeError(error: unknown) {
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const message = normalizeErrorMessage(
|
||||
error.message || "Unknown image generation error",
|
||||
record.status,
|
||||
);
|
||||
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message || "Unknown image generation error",
|
||||
message,
|
||||
stack: error.stack,
|
||||
status: record.status,
|
||||
code: record.code,
|
||||
@@ -929,11 +989,34 @@ function serializeError(error: unknown) {
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Unknown image generation error",
|
||||
message: normalizeErrorMessage("Unknown image generation error"),
|
||||
detail: safeJson(error),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(message: string, status?: number) {
|
||||
if (status === 524) {
|
||||
return `${message}\n\n提示:本服务允许等待 ${Math.round(openAITimeoutMs / 1000)} 秒,但上游网关提前返回了 524。请提高渠道 Base URL 前置网关的 proxy/read timeout,或改用允许更长图片生成请求的上游地址。`;
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("'utf-8' codec can't decode byte 0x89") ||
|
||||
message.includes("invalid start byte")
|
||||
) {
|
||||
return `${message}\n\n提示:上游接口没有按 OpenAI 图片编辑 multipart 格式解析上传的图片。当前画布因为连接了参考图片会走图像编辑模式,请确认该渠道支持 /v1/images/edits;如果只想文生图,请断开参考图片连接。`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function getOpenAITimeoutMs() {
|
||||
const configured = Number(process.env.OPENAI_IMAGE_TIMEOUT_MS);
|
||||
if (!Number.isFinite(configured) || configured <= 0) {
|
||||
return defaultOpenAITimeoutMs;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(configured), 30_000), 1_800_000);
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user