first commit

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
HouYunFei
2026-05-19 07:53:53 +08:00
commit 472bf8b732
133 changed files with 16869 additions and 0 deletions
+1554
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+21
View File
@@ -0,0 +1,21 @@
import type { NextConfig } from "next";
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
const webDir = dirname(fileURLToPath(import.meta.url));
const version = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
export default function nextConfig(phase: string): NextConfig {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
return {
env: {
NEXT_PUBLIC_APP_VERSION: version,
},
async rewrites() {
return isDev ? [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }] : [];
},
};
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "infinite-canvas",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -H 0.0.0.0",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "3.0.0-beta.3",
"@tanstack/react-query": "^5.100.9",
"antd": "^6.4.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
"next": "16.2.3",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32 8L58 54H46L32 29L18 54H6L32 8Z" fill="currentColor"/>
<path d="M32 40L40 54H24L32 40Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

+162
View File
@@ -0,0 +1,162 @@
"use client";
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import type { AdminAsset } from "@/services/api/admin";
import { useAdminAssets } from "../hooks/use-admin-assets";
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
const typeOptions = [
{ label: "全部类型", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
];
const editTypeOptions = typeOptions.slice(1);
export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const { message } = App.useApp();
const [form] = Form.useForm<AssetFormValues>();
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
const copyValue = async (value: string) => {
await navigator.clipboard.writeText(value);
message.success("已复制");
};
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
await saveAdminAsset({
...editingAsset,
...value,
type: nextType,
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
});
setEditingAsset(null);
};
const columns: ProColumns<AdminAsset>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
},
{
title: "类型",
dataIndex: "type",
width: 84,
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "分类",
dataIndex: "category",
width: 120,
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<AdminAsset>
rowKey="id"
columns={columns}
dataSource={assets}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}></Button>]}
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
/>
</Flex>
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="category" label="分类"><Input /></Form.Item>
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
</Form>
</Modal>
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}>
{detailAsset ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title>
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
</Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => void copyValue(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
</Flex>
) : null}
</Modal>
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</main>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminAsset, fetchAdminAssets, saveAdminAsset, type AdminAsset } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminAssets() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [type, setType] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, type, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
queryState.page = 1;
}
setKeyword(queryState.keyword);
setType(queryState.type);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const refreshAssets = async () => {
await query.refetch();
};
const saveAsset = async (asset: Partial<AdminAsset>) => {
await saveMutation.mutateAsync(asset);
};
const deleteAsset = async (id: string) => {
await deleteMutation.mutateAsync(id);
};
const data = query.data;
const isLoading = query.isFetching || saveMutation.isPending || deleteMutation.isPending;
return {
assets: data?.items || [],
tags: data?.tags || [],
keyword,
kind: type,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading,
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshAssets,
saveAsset,
deleteAsset,
};
}
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
import type { Prompt } from "@/services/api/prompts";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminPrompts() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token),
retry: false,
});
const promptsQuery = useQuery({
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "同步失败");
},
});
const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("提示词已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
useEffect(() => {
if (categoriesQuery.isError) {
const errorMessage = categoriesQuery.error instanceof Error ? categoriesQuery.error.message : "读取提示词分类失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [categoriesQuery.error, categoriesQuery.isError, clearSession, message]);
useEffect(() => {
if (promptsQuery.isError) {
const errorMessage = promptsQuery.error instanceof Error ? promptsQuery.error.message : "读取提示词失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
clearSession();
}
}
}, [clearSession, message, promptsQuery.error, promptsQuery.isError]);
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, category, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
queryState.page = 1;
}
setKeyword(queryState.keyword);
setCategory(queryState.category);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const refreshPrompts = async () => {
await categoriesQuery.refetch();
await promptsQuery.refetch();
};
const data = promptsQuery.data;
const isLoading = categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending;
return {
categories: categoriesQuery.data || [],
prompts: data?.items || [],
tags: data?.tags || [],
keyword,
category,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading,
isSyncing: syncMutation.isPending,
syncCategory: (category: string) => syncMutation.mutateAsync(category),
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshPrompts,
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
};
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography } from "antd";
import { LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { UserStatusActions } from "@/components/user-status-actions";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
export default function AdminLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const token = useUserStore((state) => state.token);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
useEffect(() => {
if (!isReady) return;
if (!token) {
router.replace("/login?redirect=/admin");
return;
}
if (user?.role !== "admin") {
router.replace("/");
}
}, [isReady, router, token, user?.role]);
if (!isReady || !token || user?.role !== "admin") {
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: "var(--background)" }}>
<span />
</div>
);
}
return (
<Layout className="admin-layout" hasSider style={{ height: "100vh", overflow: "hidden" }}>
<Layout.Sider width={232} style={{ height: "100vh", overflow: "hidden" }}>
<Flex className="admin-brand" align="center" gap={12} style={{ height: 56, padding: "0 20px" }}>
<span className="admin-logo" aria-hidden />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={{ borderInlineEnd: 0, padding: "12px 8px" }}
items={[
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link> },
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link> },
]}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer"></Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>退</Button>
</Flex>
</Layout.Sider>
<Layout>
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: 56, padding: "0 24px" }}>
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions
version={appVersion}
theme={colorTheme}
onThemeChange={setTheme}
showConfig={false}
userName={user.username}
menuItems={[{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout }]}
/>
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/assets");
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import type { Prompt } from "@/services/api/prompts";
import { useAdminPrompts } from "../hooks/use-admin-prompts";
export default function AdminPromptsPage() {
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts();
const { message } = App.useApp();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
const [isSyncOpen, setIsSyncOpen] = useState(false);
const defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
const copyPrompt = async (value: string) => {
await navigator.clipboard.writeText(value);
message.success("已复制");
};
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
setEditingPrompt(null);
};
const columns: ProColumns<Prompt>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
},
{
title: "分类",
dataIndex: "category",
width: 150,
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<Prompt>
rowKey="id"
columns={columns}
dataSource={prompts}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}></Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}></Button>]}
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
/>
</Flex>
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
</Form>
</Modal>
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}>
{detailPrompt ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title>
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
</Flex>
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => void copyPrompt(detailPrompt.prompt)}></Button>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
pagination={false}
columns={[
{ title: "远程源", dataIndex: "name", render: (_, item) => <Flex align="center" gap={8}>{item.name}{item.githubUrl ? <Typography.Link href={item.githubUrl} target="_blank"><ExportOutlined /></Typography.Link> : null}</Flex> },
{ title: "", key: "sync", width: 96, align: "right", render: (_, item) => <Button type="primary" loading={isSyncing} onClick={async () => { try { await syncCategory(item.category); setIsSyncOpen(false); } catch {} }}></Button> },
]}
/>
</Modal>
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingPrompt?.title}
</Modal>
</main>
);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { Copy, FolderPlus, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
import axios from "axios";
import { cn } from "@/lib/utils";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
import { uploadImage } from "@/services/image-storage";
const PAGE_SIZE = 12;
export default function AssetLibraryPage() {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false,
});
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
const copyText = async (value: string) => {
await navigator.clipboard.writeText(value);
message.success("已复制");
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.url)}></Button> : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
}
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
const cover = asset.coverUrl;
return (
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}></Button>
</div>
</Card>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return await blobToDataUrl(blob);
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
+382
View File
@@ -0,0 +1,382 @@
"use client";
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
type AssetFormValues = {
kind: AssetKind;
title: string;
coverUrl: string;
tags: string[];
source?: string;
note?: string;
content?: string;
};
type ImageDraft = ImageAsset["data"] | null;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
];
export default function AssetsPage() {
const { message } = App.useApp();
const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
const coverUrl = Form.useWatch("coverUrl", form) || "";
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]);
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
return validAssets.filter((asset) => {
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
if (!query) return true;
return assetSearchText(asset).includes(query);
});
}, [validAssets, keyword, kindFilter]);
const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredAssets.slice(start, start + pageSize);
}, [filteredAssets, page, pageSize]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
setPage((value) => Math.min(value, maxPage));
}, [filteredAssets.length, pageSize]);
const openCreate = () => {
setEditingAsset(null);
setImageDraft(null);
setFormKind("text");
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
setIsAssetOpen(true);
};
const openEdit = (asset: Asset) => {
setEditingAsset(asset);
setFormKind(asset.kind);
setImageDraft(asset.kind === "image" ? asset.data : null);
form.setFieldsValue({
kind: asset.kind,
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags || [],
source: asset.source,
note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
});
setIsAssetOpen(true);
};
const saveAsset = async () => {
const values = await form.validateFields();
const base = {
title: values.title.trim(),
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
metadata: editingAsset?.metadata || { source: "manual" },
};
if (values.kind === "text") {
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
} else {
if (!imageDraft) {
message.error("请选择图片文件");
return;
}
const asset = { ...base, kind: "image" as const, data: imageDraft };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
}
message.success(editingAsset ? "素材已更新" : "素材已保存");
setIsAssetOpen(false);
};
const readCoverFile = async (file?: File) => {
if (!file) return;
const dataUrl = await readFileAsDataUrl(file);
form.setFieldValue("coverUrl", dataUrl);
};
const readImageFile = async (file?: File) => {
if (!file || !file.type.startsWith("image/")) return;
const image = await uploadImage(file);
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
setImageDraft(draft);
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
};
const copyText = async (asset: Asset) => {
if (asset.kind !== "text") return;
await navigator.clipboard.writeText(asset.data.content);
message.success("文本已复制");
};
const downloadImage = (asset: Asset) => {
if (asset.kind !== "image") return;
const link = document.createElement("a");
link.href = asset.data.dataUrl;
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
link.click();
};
const confirmDelete = () => {
if (!deletingAsset) return;
removeAsset(deletingAsset.id);
message.success("素材已删除");
setDeletingAsset(null);
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input.Search className="w-full" size="large" allowClear prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} />
</div>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{kindOptions.map((option) => (
<Tag.CheckableTag key={option.value} checked={kindFilter === option.value} className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")} onChange={() => { setPage(1); setKindFilter(option.value as AssetKind | "all"); }}>
{option.label}
</Tag.CheckableTag>
))}
</div>
</div>
<button type="button" className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300" onClick={openCreate}>
</button>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyText}
onDownload={downloadImage}
onDelete={() => setDeletingAsset(asset)}
/>
))}
</div>
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination
current={page}
pageSize={pageSize}
total={filteredAssets.length}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
</div>
</div>
</main>
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型">
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}></Button>
</Space.Compact>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 提示词库" />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="可选" />
</Form.Item>
</div>
{formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
</Form.Item>
) : (
<Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}></Button>
{imageDraft ? <Typography.Text type="secondary" className="ml-3 text-xs">{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}</Typography.Text> : <Typography.Text type="secondary" className="ml-3 text-xs"></Typography.Text>}
</div>
</Form.Item>
)}
</Form>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
<Typography.Text strong></Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
{coverUrl || imageDraft?.dataUrl ? <img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>}
<div className="p-4">
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.length ? tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>) : <Tag className="m-0"></Tag>}
</div>
</div>
</div>
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
void readCoverFile(event.target.files?.[0]);
event.target.value = "";
}} />
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
void readImageFile(event.target.files?.[0]);
event.target.value = "";
}} />
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyText} onDownload={downloadImage} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</div>
);
}
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
const summary = assetSummary(asset);
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
</button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Typography.Text type="secondary" className="mt-1 block text-xs">{asset.source || "未标注来源"}</Typography.Text>
</div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{(asset.tags || []).slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}></Button>
{asset.kind === "text" ? <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}></Button> : null}
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}></Button> : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}></Button>
</div>
</Card>
);
}
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
return (
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (
<div className="space-y-5">
{cover ? <Image src={cover} alt={asset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title>
<Space size={[4, 4]} wrap>
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}
</Space>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{asset.kind === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>}
</div>
{asset.note ? <div><Typography.Text type="secondary"></Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null}
<Space>
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}></Button> : null}
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}></Button> : null}
</Space>
</div>
) : null}
</Drawer>
);
}
function assetSummary(asset: Asset) {
if (asset.kind === "text") return asset.data.content;
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
}
function assetSearchText(asset: Asset) {
return [
asset.title,
asset.source || "",
asset.note || "",
(asset.tags || []).join(" "),
asset.kind === "text" ? asset.data.content : asset.data.mimeType,
].join(" ").toLowerCase();
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import CanvasClientPage from "./canvas-client-page";
export default function CanvasPage() {
return <CanvasClientPage />;
}
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
import { Search } from "lucide-react";
import axios from "axios";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
export type AssetPickerTab = "my-assets" | "library";
export type InsertAssetPayload =
| { kind: "text"; content: string; title: string }
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
type Props = {
open: boolean;
defaultTab?: AssetPickerTab;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
};
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
useEffect(() => {
if (open) setActiveTab(defaultTab);
}, [open, defaultTab]);
return (
<Modal
title="选择素材"
open={open}
onCancel={onClose}
footer={null}
width={860}
destroyOnHidden
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as AssetPickerTab)}
items={[
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
]}
/>
</Modal>
);
}
const PAGE_SIZE = 8;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
];
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [page, setPage] = useState(1);
const [inserting, setInserting] = useState<string | null>(null);
const query = useQuery({
queryKey: ["asset-picker-library", keyword, kindFilter, page],
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
retry: false,
});
const items = query.data?.items || [];
const total = query.data?.total || 0;
const handleInsert = async (asset: AssetLibraryItem) => {
try {
setInserting(asset.id);
if (asset.type === "text") {
onInsert({ kind: "text", content: asset.content, title: asset.title });
} else {
const dataUrl = await remoteImageToDataUrl(asset.url);
onInsert({ kind: "image", dataUrl, title: asset.title });
}
} catch {
message.error("插入失败");
} finally {
setInserting(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
/>
<div className="flex gap-1.5">
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
<Tag.CheckableTag
key={opt.value || "all"}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{query.isLoading ? (
<div className="flex justify-center py-16"><Spin /></div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard
key={asset.id}
title={asset.title}
kind={asset.type}
cover={asset.coverUrl}
loading={inserting === asset.id}
onClick={() => void handleInsert(asset)}
/>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
}
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
return (
<button
type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick}
disabled={loading}
>
{cover ? (
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{title}
</div>
)}
<div className="p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag>
</div>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
<Spin size="small" />
</div>
)}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">
</div>
</button>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets
.filter((a) => a.kind === "text" || a.kind === "image")
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
}, [assets, keyword, kindFilter]);
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
setPage((v) => Math.min(v, maxPage));
}, [filtered.length]);
const handleInsert = (asset: Asset) => {
if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
} else {
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
/>
<div className="flex gap-1.5">
{kindOptions.map((opt) => (
<Tag.CheckableTag
key={opt.value}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
}
@@ -0,0 +1,621 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
import { Button, ConfigProvider, Input, InputNumber, Modal, Popover, Segmented, Tooltip } from "antd";
import { motion } from "motion/react";
import { ImageGenerationPending } from "@/components/image-generation-pending";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/lib/ai-config";
import { canvasThemes } from "@/lib/canvas-theme";
import { createId } from "@/lib/id";
import { cn } from "@/lib/utils";
import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ReferenceImage } from "@/types/image";
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
type AssistantMode = "ask" | "image";
const PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000;
type CanvasAssistantPanelProps = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
sessions: CanvasAssistantSession[];
activeSessionId: string | null;
onSelectNodeIds: (ids: Set<string>) => void;
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
onInsertImage: (image: CanvasAssistantImage) => void;
onInsertText: (text: string) => void;
onPasteImage: (file: File) => void;
onCollapseStart: () => void;
onCollapse: () => void;
};
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const config = useAiConfigStore((state) => state.config);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const [width, setWidth] = useState(390);
const [view, setView] = useState<"chat" | "history">("chat");
const [mode, setMode] = useState<AssistantMode>("image");
const [prompt, setPrompt] = useState("");
const [isRunning, setIsRunning] = useState(false);
const [checkedChatIds, setCheckedChatIds] = useState<string[]>([]);
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
const [closing, setClosing] = useState(false);
const [resizing, setResizing] = useState(false);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => sessions.length ? sessions : [createSession()]);
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
useEffect(() => {
if (!sessions.length) return;
setLocalSessions(sessions);
setLocalActiveSessionId(activeSessionId);
}, [activeSessionId, sessions]);
useEffect(() => {
onSessionsChange(localSessions, localActiveSessionId);
}, [localActiveSessionId, localSessions, onSessionsChange]);
const safeSessions = localSessions.length ? localSessions : [createSession()];
const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]);
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
const messages = activeSession?.messages || [];
const hasMessages = messages.length > 0;
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
const iconButtonStyle = { color: theme.node.muted };
useEffect(() => {
setRemovedReferenceIds(new Set());
}, [selectedNodeKey]);
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session));
};
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
updateSession(sessionId, (session) => ({
...session,
title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话",
messages: [...session.messages, message],
updatedAt: new Date().toISOString(),
}));
};
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
updateSession(sessionId, (session) => ({
...session,
messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message),
updatedAt: new Date().toISOString(),
}));
};
const startChatSession = () => {
if (activeSession && activeSession.messages.length === 0) {
setLocalActiveSessionId(activeSession.id);
return;
}
const session = createSession();
setLocalSessions((prev) => [session, ...prev]);
setLocalActiveSessionId(session.id);
};
const removeSessions = (ids: string[]) => {
const next = safeSessions.filter((session) => !ids.includes(session.id));
if (!next.length) {
const session = createSession();
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
} else {
setLocalSessions(next);
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
}
cleanupImages({ sessions: next });
setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id)));
};
const clearSessions = () => {
const session = createSession();
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
setCheckedChatIds([]);
cleanupImages({ sessions: [session] });
};
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
const requestConfig = { ...config, model: nextMode === "image" ? config.imageModel || config.model : config.textModel || config.model };
if (!requestConfig.baseUrl.trim() || !requestConfig.model.trim() || !requestConfig.apiKey.trim()) {
openConfigDialog(true);
return;
}
const session = activeSession || createSession();
if (!activeSession) {
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
}
const refs = savedReferences || selectedReferences;
const userMessage: CanvasAssistantMessage = { id: createId(), role: "user", mode: nextMode, text, references: refs };
const assistantId = createId();
appendMessage(session.id, userMessage);
appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true });
setPrompt("");
setIsRunning(true);
try {
if (nextMode === "image") {
const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })));
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
updateMessage(session.id, assistantId, {
text: `生成了 ${storedImages.length} 张图片`,
images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })),
isLoading: false,
});
return;
}
const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => {
updateMessage(session.id, assistantId, { text: streamed, isLoading: false });
});
updateMessage(session.id, assistantId, { text: answer, isLoading: false });
} catch (error) {
updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false });
} finally {
setIsRunning(false);
}
};
const submit = async () => {
const text = prompt.trim();
if (!text || isRunning) return;
await sendMessage(text, mode, messages);
};
const retryMessage = (message: CanvasAssistantMessage) => {
const index = messages.findIndex((item) => item.id === message.id);
const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user");
const user = messages[userIndex];
if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references);
};
const startResize = () => {
const move = (event: MouseEvent) => setWidth(Math.min(760, Math.max(320, window.innerWidth - event.clientX)));
const stop = () => {
setResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", stop);
};
setResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
};
const collapse = () => {
setClosing(true);
onCollapseStart();
window.setTimeout(onCollapse, PANEL_MOTION_MS);
};
return (
<motion.div
className="flex shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: closing ? 0 : width + 1, opacity: closing ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
>
<motion.aside className="relative flex shrink-0 flex-col border-l" initial={{ x: 48 }} animate={{ x: closing ? 28 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}>
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center gap-2 text-sm font-medium"><Sparkles className="size-4" />{view === "history" ? "历史记录" : "画布助手(未开发)"}</div>
<div className="flex items-center gap-1">
{view === "history" ? (
<>
<Tooltip title="删除选中">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
</Tooltip>
<Tooltip title="删除全部">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
</Tooltip>
</>
) : null}
<Tooltip title={view === "history" ? "返回对话" : "历史记录"}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
</Tooltip>
<Tooltip title="新对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Plus className="size-4" />} disabled={!hasMessages} onClick={() => { startChatSession(); setView("chat"); }} />
</Tooltip>
<Tooltip title="配置">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
</Tooltip>
<Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<PanelRightClose className="size-4" />} onClick={collapse} />
</Tooltip>
</div>
</div>
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{view === "history" ? (
<AssistantHistory
sessions={historySessions}
activeSession={activeSession}
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))}
onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }}
onDelete={(id) => setDeleteChatIds([id])}
/>
) : messages.length ? (
<AssistantMessages messages={messages} onRetry={retryMessage} onInsertImage={onInsertImage} onInsertText={onInsertText} />
) : (
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
<span>Infinite Canvas</span>
<DiaTextReveal
className="absolute inset-0"
colors={["#A97CF8", "#F38CB8", "#FDCC92"]}
textColor="transparent"
duration={1.8}
startOnView={false}
text="Infinite Canvas"
/>
</div>
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
</div>
)}
</div>
{view === "chat" ? (
<AssistantComposer
mode={mode}
prompt={prompt}
isRunning={isRunning}
references={selectedReferences}
config={config}
onModeChange={setMode}
onPromptChange={setPrompt}
onSubmit={submit}
onConfigChange={updateConfig}
onMissingConfig={() => openConfigDialog(true)}
onRemoveReference={(id) => {
setRemovedReferenceIds((prev) => new Set(prev).add(id));
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
}}
onPasteImage={onPasteImage}
/>
) : null}
<Modal
title="删除对话记录?"
open={deleteChatIds.length > 0}
centered
onCancel={() => setDeleteChatIds([])}
footer={<>
<Button onClick={() => setDeleteChatIds([])}></Button>
<Button
danger
type="primary"
onClick={() => {
deleteChatIds.length === historySessions.length ? clearSessions() : removeSessions(deleteChatIds);
setDeleteChatIds([]);
}}
>
</Button>
</>}
>
<p className="text-sm opacity-60"> {deleteChatIds.length} </p>
</Modal>
</motion.aside>
</motion.div>
);
}
function AssistantComposer({
mode,
prompt,
isRunning,
references,
config,
onModeChange,
onPromptChange,
onSubmit,
onConfigChange,
onMissingConfig,
onRemoveReference,
onPasteImage,
}: {
mode: AssistantMode;
prompt: string;
isRunning: boolean;
references: CanvasAssistantReference[];
config: AiConfig;
onModeChange: (mode: AssistantMode) => void;
onPromptChange: (prompt: string) => void;
onSubmit: () => void;
onConfigChange: (key: keyof AiConfig, value: string) => void;
onMissingConfig: () => void;
onRemoveReference: (id: string) => void;
onPasteImage: (file: File) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
{references.length ? (
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
{references.map((item) => <AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />)}
</div>
) : null}
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
const file = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
if (!file) return;
event.preventDefault();
onPasteImage(file);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:text-stone-400"
style={{ color: theme.node.text }}
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<CanvasPromptLibrary onSelect={onPromptChange} />
<CanvasThemeProvider theme={theme}>
<Segmented
size="small"
className="canvas-composer-mode !rounded-full !p-0.5"
value={mode}
onChange={(value) => onModeChange(value as AssistantMode)}
options={[
{ value: "ask", label: <Tooltip title="文本"><MessageSquare className="size-4" /></Tooltip> },
{ value: "image", label: <Tooltip title="生图"><ImageIcon className="size-4" /></Tooltip> },
]}
/>
</CanvasThemeProvider>
{mode === "image" ? (
<ImageSettingsPopover config={config} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
) : (
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
)}
</div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10 shrink-0" icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} disabled={isRunning || !prompt.trim()} onClick={() => void onSubmit()} aria-label="发送" />
</div>
</div>
</div>
);
}
function ImageSettingsPopover({ config, onConfigChange, onMissingConfig }: { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const quality = config.quality || "auto";
const count = String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))));
const sizes = ["1:1", "3:2", "2:3", "16:9", "9:16", "auto"];
const customSize = sizes.includes(config.size || "auto") ? "" : config.size;
return (
<Popover
trigger="click"
placement="topLeft"
arrow={false}
overlayClassName="canvas-image-settings-popover"
color={theme.toolbar.panel}
content={(
<CanvasThemeProvider theme={theme}>
<div className="w-[330px] space-y-4" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<div className="text-base font-semibold"></div>
<div className="space-y-1.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<ModelPicker className="!h-9 !w-full !max-w-none" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} fullWidth />
</div>
<div className="space-y-1.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<Segmented block value={quality} onChange={(value) => onConfigChange("quality", String(value))} options={[{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]} />
</div>
<div className="space-y-1.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-1.5">
{sizes.map((size) => (
<Button key={size} size="small" type={(config.size || "auto") === size ? "primary" : "default"} className="!h-7 !rounded-md !px-1.5" onClick={() => onConfigChange("size", size)}>
{size}
</Button>
))}
<Input size="small" className="col-span-2 !h-7" placeholder="自定义比例" value={customSize} onChange={(event) => onConfigChange("size", event.target.value.trim() || "auto")} />
</div>
</div>
<div className="space-y-1.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-[minmax(0,1fr)_92px] gap-2">
<Segmented block value={["1", "2", "3", "4"].includes(count) ? count : undefined} onChange={(value) => onConfigChange("count", String(value))} options={["1", "2", "3", "4"].map((value) => ({ value, label: `${value}` }))} />
<InputNumber min={1} max={15} size="small" className="canvas-control-number !h-8 !w-full" value={Number(count)} onChange={(value) => onConfigChange("count", String(value || 1))} />
</div>
</div>
</div>
</CanvasThemeProvider>
)}
>
<Button size="small" type="text" className="!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5" style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">{qualityLabel(quality)} · {config.size || "auto"} · {count} </span>
</Button>
</Popover>
);
}
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
return (
<ConfigProvider theme={{ token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel }, components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text }, Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 } } }}>
{children}
</ConfigProvider>
);
}
function SettingTitle({ children, color }: { children: string; color: string }) {
return <div className="text-xs font-medium" style={{ color }}>{children}</div>;
}
function qualityLabel(value: string) {
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
}
function AssistantMessages({
messages,
onRetry,
onInsertImage,
onInsertText,
}: {
messages: CanvasAssistantMessage[];
onRetry: (message: CanvasAssistantMessage) => void;
onInsertImage: (image: CanvasAssistantImage) => void;
onInsertText: (text: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<>
{messages.map((message) => (
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
<div className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6" style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}>
{message.role === "assistant" ? <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"><MessageSquare className="size-3.5" /></div> : null}
{message.text}
</div>
{message.references?.length ? <MessageReferences message={message} /> : null}
{message.isLoading ? <ImageGenerationPending compact label={message.mode === "image" ? "正在生成图片" : "正在回答"} className="w-[250px] rounded-2xl border" /> : null}
{message.role === "assistant" && !message.isLoading ? (
<div className="flex gap-1">
<Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<RotateCcw className="size-3.5" />} onClick={() => onRetry(message)} title="重试" />
{!message.images?.length ? <Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<Plus className="size-3.5" />} onClick={() => onInsertText(message.text)} title="插入画布" /> : null}
</div>
) : null}
{message.images?.map((image) => (
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
<Button type="text" className="!h-8 !w-full !rounded-none" style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }} icon={<Plus className="size-3.5" />} onClick={() => onInsertImage(image)} title="插入画布" />
</div>
))}
</div>
))}
</>
);
}
function AssistantHistory({
sessions,
activeSession,
checkedIds,
onToggleChecked,
onOpen,
onDelete,
}: {
sessions: CanvasAssistantSession[];
activeSession: CanvasAssistantSession | null;
checkedIds: string[];
onToggleChecked: (id: string, checked: boolean) => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="space-y-1">
{sessions.map((session) => (
<div key={session.id} className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-black/5 dark:hover:bg-white/10" style={session.id === activeSession?.id ? { background: theme.node.fill } : undefined}>
<input type="checkbox" className="size-4 accent-stone-950" checked={checkedIds.includes(session.id)} onChange={(event) => onToggleChecked(session.id, event.target.checked)} />
<button type="button" className="min-w-0 flex-1 text-left text-sm" onClick={() => onOpen(session.id)}>
<span className="block truncate">{session.title}</span>
<span className="text-xs opacity-50">{session.messages.length} </span>
</button>
<Button type="text" shape="circle" size="small" className="opacity-0 transition group-hover:opacity-100" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} title="删除" />
</div>
))}
</div>
);
}
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
return (
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
{message.references?.map((item) => <AssistantReferenceChip key={item.id} item={item} />)}
</div>
);
}
function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantReference; onRemove?: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
return (
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
{item.dataUrl ? <img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" /> : <span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>{text}</span>}
{onRemove ? (
<button type="button" className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }} onClick={onRemove} aria-label="移除引用">
<X className="size-3" />
</button>
) : null}
</div>
);
}
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
}
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, text: node.metadata.content };
}
return null;
}
function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<string>) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
return Array.from(selectedNodeIds)
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node))
.map(nodeToReference)
.filter((item): item is CanvasAssistantReference => Boolean(item));
}
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
return Promise.all(messages.map(async (message, index) => {
if (message.role === "assistant") return { role: "assistant", content: message.text };
if (index !== messages.length - 1) return { role: "user", content: message.text };
const refs = message.references || [];
return {
role: "user",
content: [
...refs.flatMap((item) => item.text ? [{ type: "text" as const, text: item.text }] : []),
{ type: "text", text: message.text },
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
],
};
}));
}
function createSession(): CanvasAssistantSession {
const now = new Date().toISOString();
return { id: createId(), title: "新对话", messages: [], createdAt: now, updatedAt: now };
}
@@ -0,0 +1,253 @@
"use client";
import type { CSSProperties } from "react";
import { useState } from "react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play } from "lucide-react";
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasSizePicker } from "./canvas-size-picker";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number };
inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void;
onGenerate: (nodeId: string) => void;
};
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
const { message } = App.useApp();
const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const globalConfig = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image");
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
const targetInput = sameTypeInputs[sameTypeIndex + offset];
if (!targetInput) return;
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
const next = [...inputs];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
message.success("已调整输入顺序");
};
const startTextEdit = (input: NodeGenerationInput) => {
setEditingTextId(input.nodeId);
setEditingText(input.text || "");
};
const saveTextEdit = () => {
if (!editingTextId) return;
onTextInputChange(editingTextId, editingText);
setEditingText("");
setEditingTextId(null);
message.success("已保存文本提示词");
};
return (
<div className="flex h-full w-full flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div>
<Segmented
size="small"
className="canvas-config-mode !rounded-md !p-0.5"
value={mode}
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
options={[
{ value: "image", label: <span className="inline-flex items-center gap-1"><ImageIcon className="size-3.5" /></span> },
{ value: "text", label: <span className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" /></span> },
]}
/>
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]"
style={chipStyle}
onClick={() => setPreviewOpen(true)}
>
<Eye className="size-3.5" />
</button>
</div>
<div className="mb-2 grid min-w-0 grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2">
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
onClick={() => onGenerate(node.id)}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div>
)}
>
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
{inputs.length ? (
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => <ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />)}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5">
{textInputs.map((input, index) => <TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />)}
</div>
</PreviewSection>
</div>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{editingTextId ? (
<>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}></Button>
</div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}></Button>
<Button size="small" type="primary" onClick={saveTextEdit}></Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)}
</div>
</div>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
)}
</div>
</Modal>
</div>
);
}
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
return (
<section>
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
<div className="text-xs font-semibold">{title}</div>
<div className="text-[11px] opacity-50">{count} </div>
</div>
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
</section>
);
}
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
<div className="min-w-0">
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div>
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
</div>
<div className="flex justify-end gap-1">
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) {
if (!input.image) return null;
return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageIndex + 1}</span>
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<>
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</>
);
}
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<div className="absolute inset-x-1 bottom-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</div>
);
}
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
return (
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
<span>{label}</span>
<span className="font-medium">{value}</span>
</div>
);
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
}
@@ -0,0 +1,73 @@
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
export function ConnectionPath({
connection,
from,
to,
active,
onSelect,
}: {
connection: CanvasConnection;
from: CanvasNodeData;
to: CanvasNodeData;
active: boolean;
onSelect: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startX = from.position.x + from.width;
const startY = from.position.y + from.height / 2;
const endX = to.position.x;
const endY = to.position.y + to.height / 2;
const dx = Math.abs(endX - startX);
const curvature = Math.max(dx * 0.5, 50);
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
return (
<g>
<path
data-connection-id={connection.id}
d={pathD}
stroke="transparent"
strokeWidth="16"
fill="none"
style={{ cursor: "pointer", pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
/>
<path
d={pathD}
stroke={active ? theme.node.activeStroke : theme.node.muted}
strokeWidth={active ? 3 : 2}
strokeOpacity={active ? 1 : 0.82}
fill="none"
style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }}
/>
</g>
);
}
export function ActiveConnectionPath({
node,
handle,
mouseWorld,
}: {
node?: CanvasNodeData;
handle: ConnectionHandle;
mouseWorld: Position;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!node) return null;
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
const distance = Math.abs(endX - startX);
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "../types";
export function CanvasNodeContextMenu({
menu,
onClose,
onDuplicate,
onDelete,
}: {
menu: ContextMenuState;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => {
const close = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Element && target.closest(".ant-popover")) return;
onClose();
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, [onClose]);
return (
<div
className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl"
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
</div>
);
}
function MenuButton({
icon,
label,
onClick,
danger = false,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80"
style={{ color: danger ? "#f87171" : theme.node.text }}
onClick={onClick}
>
{icon}
<span>{label}</span>
</button>
);
}
@@ -0,0 +1,33 @@
"use client";
import { Button, Modal } from "antd";
import { useAssetStore } from "@/stores/use-asset-store";
import { useCanvasStore } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() {
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const confirm = () => {
deleteProjects(ids);
cleanupImages();
removeSelectedIds(ids);
setDeleteIds([]);
};
return (
<Modal
title="删除画布?"
open={ids.length > 0}
centered
onCancel={() => setDeleteIds([])}
footer={<><Button onClick={() => setDeleteIds([])}></Button><Button danger type="primary" onClick={confirm}></Button></>}
>
<p className="text-sm text-stone-500"> {ids.length} 线</p>
</Modal>
);
}
@@ -0,0 +1,154 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
export function Minimap({
nodes,
viewport,
viewportSize,
onViewportChange,
}: {
nodes: CanvasNodeData[];
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onViewportChange: (viewport: ViewportTransform) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const width = 240;
const height = 160;
const { worldBounds, scale, offset } = useMemo(() => {
if (!nodes.length) {
return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + node.width);
maxY = Math.max(maxY, node.position.y + node.height);
});
minX -= 500;
minY -= 500;
maxX += 500;
maxY += 500;
const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY;
const nextScale = Math.min(width / boundsWidth, height / boundsHeight);
const mapContentW = boundsWidth * nextScale;
const mapContentH = boundsHeight * nextScale;
return {
worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight },
scale: nextScale,
offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 },
};
}, [nodes]);
const toMinimap = useCallback(
(worldX: number, worldY: number) => {
return {
x: (worldX - worldBounds.x) * scale + offset.x,
y: (worldY - worldBounds.y) * scale + offset.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const toWorld = useCallback(
(minimapX: number, minimapY: number) => {
return {
x: (minimapX - offset.x) / scale + worldBounds.x,
y: (minimapY - offset.y) / scale + worldBounds.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const viewportRect = useMemo(() => {
const vx = -viewport.x / viewport.k;
const vy = -viewport.y / viewport.k;
const vw = viewportSize.width / viewport.k;
const vh = viewportSize.height / viewport.k;
const p1 = toMinimap(vx, vy);
const p2 = toMinimap(vx + vw, vy + vh);
return {
x: p1.x,
y: p1.y,
w: Math.max(p2.x - p1.x, 4),
h: Math.max(p2.y - p1.y, 4),
};
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const updateViewportFromEvent = (event: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
onViewportChange({
x: viewportSize.width / 2 - world.x * viewport.k,
y: viewportSize.height / 2 - world.y * viewport.k,
k: viewport.k,
});
};
return (
<div
className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm"
style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
<div
ref={containerRef}
className="relative h-full w-full cursor-crosshair"
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
updateViewportFromEvent(event);
}}
onPointerMove={(event) => {
if (isDragging) updateViewportFromEvent(event);
}}
onPointerUp={() => setIsDragging(false)}
onPointerLeave={() => setIsDragging(false)}
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
return (
<div
key={node.id}
className="absolute rounded-[1px]"
style={{
left: pos.x,
top: pos.y,
width: Math.max(node.width * scale, 2),
height: Math.max(node.height * scale, 2),
backgroundColor: color,
opacity: 0.8,
}}
/>
);
})}
<div
className="pointer-events-none absolute border"
style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react";
export type CanvasImageAngleParams = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
const defaultParams: CanvasImageAngleParams = {
horizontalAngle: 0,
pitchAngle: 9,
cameraDistance: 4.8,
wideAngle: false,
};
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (params: CanvasImageAngleParams) => void;
}) {
const [params, setParams] = useState(defaultParams);
useEffect(() => {
if (open) setParams(defaultParams);
}, [dataUrl, open]);
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">AI </h2>
<p className="mt-1 text-sm opacity-60"></p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
<div className="grid flex-1 place-items-center">
<div className="relative">
<img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} />
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
</div>
</div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}></Button>
</div>
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<Segmented className="w-fit" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI </Button>
</div>
</div>
</Modal>
);
}
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return (
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">{label}</span>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
</div>
);
}
function previewTransform(params: CanvasImageAngleParams) {
const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0);
return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`;
}
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = {
x: number;
y: number;
width: number;
height: number;
};
type DragMode = "move" | "resize";
type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
export function CanvasNodeCropDialog({
dataUrl,
open,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (crop: CanvasImageCropRect) => void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
useEffect(() => {
if (open) setCrop(defaultCrop);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
};
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-4">
<div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? <span> {image.width} x {image.height}</span> : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}></Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}></Button>
</div>
</div>
</Modal>
);
}
function CropMask({ crop }: { crop: CanvasImageCropRect }) {
return (
<>
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
</>
);
}
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
}
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect {
let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy;
if (handle.includes("w")) {
next.x = crop.x + dx;
next.width = crop.width - dx;
}
if (handle.includes("n")) {
next.y = crop.y + dy;
next.height = crop.height - dy;
}
if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width;
next.height = size / box.height;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height);
return next;
}
function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
}
function handleStyle(handle: ResizeHandle) {
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
return { top, left, cursor: `${handle}-resize` };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatRatio(width: number, height: number) {
const divisor = gcd(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
}
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : Math.max(1, a);
}
@@ -0,0 +1,100 @@
import type { ChatCompletionMessage } from "@/services/api/image";
import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type NodeGenerationContext = {
prompt: string;
referenceImages: ReferenceImage[];
textCount: number;
imageCount: number;
};
export type NodeGenerationInput = {
nodeId: string;
type: "text" | "image";
title: string;
text?: string;
image?: ReferenceImage;
};
export function buildNodeGenerationContext(
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
prompt: string,
): NodeGenerationContext {
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages,
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length,
};
}
export function buildNodeGenerationInputs(
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
): NodeGenerationInput[] {
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
});
}
export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompletionMessage[] {
if (!context.referenceImages.length) {
return [{ role: "user", content: context.prompt }];
}
return [
{
role: "user",
content: [
{ type: "text" as const, text: context.prompt },
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
],
},
];
}
export async function hydrateNodeGenerationContext(context: NodeGenerationContext) {
const { imageToDataUrl } = await import("@/services/image-storage");
return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) };
}
function readNodeTextInput(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return node.metadata?.prompt || "";
}
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
};
}
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections
.filter((connection) => connection.toNodeId === nodeId)
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const order = target?.metadata?.inputOrder || [];
return [
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
...upstreamNodes.filter((node) => !order.includes(node.id)),
];
}
@@ -0,0 +1,191 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Modal, Segmented, Tooltip } from "antd";
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
type CanvasNodeHoverToolbarProps = {
node: CanvasNodeData | null;
viewport: ViewportTransform;
onKeep: (nodeId: string) => void;
onLeave: () => void;
onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
};
export function CanvasNodeHoverToolbar({
node,
viewport,
onKeep,
onLeave,
onInfo,
onEditText,
onDecreaseFont,
onIncreaseFont,
onToggleDialog,
onGenerateImage,
onUpload,
onDownload,
onSaveAsset,
onCrop,
onAngle,
onRetry,
onToggleFreeResize,
onDelete,
}: CanvasNodeHoverToolbarProps) {
if (!node) return null;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const top = viewport.y + node.position.y * viewport.k - 14;
const isImage = node.type === CanvasNodeType.Image;
const hasImage = isImage && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage;
const canRetry = node.metadata?.status === "error";
const hasSpecificTools = canRetry || isText || isImage || isConfig;
return (
<div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={onLeave}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
{hasSpecificTools ? <ToolbarDivider /> : null}
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
{hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
{hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
{hasImage ? <ToolbarAction title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"} label={node.metadata?.freeResize ? "自由比例" : "锁比例"} icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />} onClick={() => onToggleFreeResize(node)} active={node.metadata?.freeResize} /> : null}
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
</div>
);
}
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
const json = useMemo(() => {
if (!node) return "";
return JSON.stringify(node, (key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
return value;
}, 2);
}, [node]);
useEffect(() => {
if (open) setView("info");
}, [node?.id, open]);
const title = (
<div className="flex items-center justify-between gap-4 pr-12">
<span></span>
<Segmented
size="small"
value={view}
onChange={(value) => setView(value as "info" | "json")}
options={[
{ label: "信息", value: "info" },
{ label: "JSON", value: "json" },
]}
/>
</div>
);
return (
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
{node ? (
<div className="h-[56vh] min-h-[360px] text-sm">
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>{node.metadata.errorDetails}</div> : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
</div>
) : null}
</Modal>
);
}
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon}
<span>{label}</span>
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
</span>
</button>
</Tooltip>
);
}
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
</button>
</Tooltip>
);
}
function ToolbarDivider() {
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
}
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
<span className="opacity-50">{label}</span>
<span className="min-w-0 whitespace-pre-wrap break-words">{value}</span>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle } from "lucide-react";
import { Button, InputNumber } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasSizePicker } from "./canvas-size-picker";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
type CanvasNodePromptPanelProps = {
node: CanvasNodeData;
isRunning: boolean;
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
const globalConfig = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]);
const updatePrompt = (value: string) => {
setPrompt(value);
if (!isEditingExistingContent) onPromptChange(node.id, value);
};
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return;
onGenerate(node.id, mode, text);
setPrompt("");
};
return (
<div
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<textarea
value={prompt}
onChange={(event) => updatePrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
submit();
}}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={mode === "image" ? hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容" : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
/>
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CanvasPromptLibrary onSelect={updatePrompt} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
{mode === "image" ? (
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} />
) : null}
{mode === "image" ? (
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 shrink-0 !w-[58px]" value={Math.floor(Math.abs(Number(config.count)) || 1)} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
) : null}
</div>
<Button
type="primary"
shape="circle"
className="!h-10 !w-10 !min-w-10 shrink-0"
disabled={isRunning || !prompt.trim()}
onClick={submit}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
aria-label="生成"
/>
</div>
</div>
);
}
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text ? "text" : "image";
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
}
@@ -0,0 +1,615 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Image as ImageIcon, RefreshCw, Star } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff";
type CanvasNodeProps = {
data: CanvasNodeData;
scale: number;
isSelected: boolean;
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
isConnecting: boolean;
editRequestNonce?: number;
showPanel: boolean;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
batchExpanded?: boolean;
batchClosing?: boolean;
batchOpening?: boolean;
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onContentChange: (nodeId: string, content: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
};
type NodeContentRendererProps = {
node: CanvasNodeData;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
isEditingContent: boolean;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
};
export const CanvasNode = React.memo(function CanvasNode({
data,
scale,
isSelected,
isRelated,
isFocusRelated,
isConnectionTarget,
isConnecting,
editRequestNonce = 0,
showPanel,
renderPanel,
renderNodeContent,
batchCount = 0,
batchExpanded = false,
batchClosing = false,
batchOpening = false,
batchRecovering = false,
batchMotion,
onMouseDown,
onHoverStart,
onHoverEnd,
onConnectStart,
onResize,
onContentChange,
onToggleBatch,
onSetBatchPrimary,
onRetry,
onGenerateImage,
onContextMenu,
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const [isEditingContent, setIsEditingContent] = useState(false);
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0,
startWidth: 0,
startHeight: 0,
keepRatio: false,
ratio: 1,
});
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const handleWheel = (event: WheelEvent) => event.stopPropagation();
textarea.addEventListener("wheel", handleWheel, { passive: false });
return () => textarea.removeEventListener("wheel", handleWheel);
}, [data.type, isEditingContent]);
useEffect(() => {
if (!isEditingContent) return;
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
}, [isEditingContent]);
useEffect(() => {
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
setIsEditingContent(true);
}, [data.type, editRequestNonce]);
useEffect(() => {
if (!isEditingContent) return;
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (isEditingContent && textareaRef.current?.contains(target)) return;
setIsEditingContent(false);
};
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
}, [isEditingContent]);
const handleResizeMove = useCallback(
(event: MouseEvent) => {
if (!resizeRef.current.isResizing) return;
const dx = (event.clientX - resizeRef.current.startX) / scale;
const dy = (event.clientY - resizeRef.current.startY) / scale;
const minWidth = 220;
const minHeight = 160;
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
const fromLeft = resizeRef.current.corner.includes("left");
const fromTop = resizeRef.current.corner.includes("top");
const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx));
const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy));
let width = rawWidth;
let height = rawHeight;
if (resizeRef.current.keepRatio) {
const ratio = resizeRef.current.ratio;
if (Math.abs(dx) >= Math.abs(dy)) {
height = width / ratio;
} else {
width = height * ratio;
}
if (height < minHeight) {
height = minHeight;
width = height * ratio;
}
if (width < minWidth) {
width = minWidth;
height = width / ratio;
}
}
onResize(data.id, width, height, {
x: fromLeft ? startRight - width : resizeRef.current.startLeft,
y: fromTop ? startBottom - height : resizeRef.current.startTop,
});
},
[data.id, onResize, scale],
);
const handleResizeUp = useCallback(() => {
resizeRef.current.isResizing = false;
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
}, [handleResizeMove]);
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
event.stopPropagation();
event.preventDefault();
resizeRef.current = {
isResizing: true,
corner,
startX: event.clientX,
startY: event.clientY,
startLeft: data.position.x,
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: data.type === CanvasNodeType.Image && !data.metadata?.freeResize,
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
window.addEventListener("mouseup", handleResizeUp);
};
useEffect(() => {
return () => {
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
};
}, [handleResizeMove, handleResizeUp]);
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
style={{
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
width: data.width,
height: data.height,
transition: "box-shadow 200ms ease",
contain: "layout style",
}}
onMouseEnter={() => {
setHovered(true);
onHoverStart(data.id);
}}
onMouseLeave={() => {
setHovered(false);
onHoverEnd(data.id);
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: hasImageContent ? "transparent" : theme.node.fill,
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
if (isBatchRoot) {
event.stopPropagation();
onToggleBatch?.(data.id);
return;
}
if (data.type !== CanvasNodeType.Text) return;
event.stopPropagation();
setIsEditingContent(true);
}}
>
<div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={{
background: hasImageContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
animation: data.metadata?.batchRootId
? batchClosing
? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both"
: "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both"
: undefined,
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
} as React.CSSProperties}
>
<NodeContent
node={data}
theme={theme}
isEditingContent={isEditingContent}
textareaRef={textareaRef}
isBatchRoot={isBatchRoot}
batchCount={batchCount}
batchExpanded={batchExpanded}
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
/>
</div>
{!hasImageContent ? (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }}
/>
) : null}
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</div>
<ConnectionHandleDot
side="left"
visible={hovered || isSelected || isConnecting}
onMouseDown={(event) => onConnectStart(event, data.id, "target")}
/>
<ConnectionHandleDot
side="right"
visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)}
onMouseDown={(event) => onConnectStart(event, data.id, "source")}
/>
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
function NodeContent(props: NodeContentRendererProps) {
if (props.node.type === CanvasNodeType.Config && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return <Renderer {...props} />;
}
const nodeContentRenderers = {
[CanvasNodeType.Text]: TextContent,
[CanvasNodeType.Image]: ImageNodeContent,
[CanvasNodeType.Config]: EmptyImageContent,
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[10px] tracking-[0.2em]"></span>
</div>
);
}
function ErrorContent({
node,
theme,
onRetry,
}: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onRetry?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
</button>
</div>
);
}
function TextContent({
node,
theme,
isEditingContent,
textareaRef,
onContentChange,
onStopEditing,
onGenerateImage,
}: NodeContentRendererProps) {
return (
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
<button
type="button"
className="absolute right-3 top-3 z-20 inline-flex h-8 items-center gap-1 rounded-full border px-2.5 text-xs font-medium opacity-85 backdrop-blur-md transition hover:scale-[1.02] hover:opacity-100"
style={{ background: `${theme.toolbar.panel}dd`, borderColor: theme.node.stroke, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onGenerateImage?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title="用文本生图"
aria-label="用文本生图"
>
<ImageIcon className="size-3.5" />
</button>
{isEditingContent ? (
<textarea
ref={textareaRef}
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
value={node.metadata?.content || ""}
onChange={(event) => onContentChange(node.id, event.target.value)}
onBlur={onStopEditing}
onKeyDown={(event) => {
if (event.key === "Escape") onStopEditing();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
) : (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
onWheel={(event) => event.stopPropagation()}
>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}></span>}
</div>
)}
</div>
);
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content = props.node.metadata?.status === "loading"
? <LoadingContent theme={props.theme} />
: props.node.metadata?.status === "error"
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
: <EmptyImageContent {...props} isBatchRoot={false} />;
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
return (
<ImageContent
node={props.node}
isBatchRoot={props.isBatchRoot}
batchCount={props.batchCount}
batchExpanded={props.batchExpanded}
batchOpening={props.batchOpening}
batchRecovering={props.batchRecovering}
onToggleBatch={props.onToggleBatch}
onSetBatchPrimary={props.onSetBatchPrimary}
/>
);
}
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" />
</div>
<span className="text-[10px] tracking-[0.18em] opacity-50"></span>
</div>
);
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>{content}</BatchFrame>;
return (
content
);
}
function ImageContent({
node,
isBatchRoot,
batchCount,
batchExpanded,
batchOpening,
batchRecovering,
onToggleBatch,
onSetBatchPrimary,
}: {
node: CanvasNodeData;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchChild = Boolean(node.metadata?.batchRootId);
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
<img
src={node.metadata!.content!}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none rounded-[inherit] ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
{isBatchRoot ? (
<button
type="button"
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
aria-label={batchExpanded ? "图片组已展开" : "图片组已收起"}
onClick={(event) => {
event.stopPropagation();
onToggleBatch?.();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<span className="leading-none text-[#2f80ff]">{batchCount}</span>
<ChevronRight className={`size-3.5 opacity-55 transition-transform ${batchExpanded ? "rotate-90" : ""}`} />
</button>
) : null}
{isBatchChild ? (
<button
type="button"
className="absolute right-3 top-3 z-30 flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium opacity-0 shadow-[0_8px_20px_rgba(68,64,60,.13)] backdrop-blur-md transition group-hover/batch:opacity-100 hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onSetBatchPrimary?.();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Star className="size-3.5 text-[#2f80ff]" />
</button>
) : null}
</BatchFrame>
);
}
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchRoot = batchCount > 1;
return (
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}>
{isBatchRoot ? (
<div className="pointer-events-none absolute inset-0 overflow-visible">
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
<div
key={index}
className="absolute rounded-[inherit] border shadow-[0_14px_34px_rgba(68,64,60,.16)] transition-all duration-300 group-hover/batch:translate-x-2"
style={{
inset: 0,
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
borderColor: theme.node.stroke,
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
zIndex: -index - 1,
}}
/>
))}
</div>
) : null}
{children}
</div>
);
}
function ResizeHandle({
corner,
onMouseDown,
}: {
corner: ResizeCorner;
onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void;
}) {
const positionClass = {
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
"bottom-left": "-bottom-[14px] -left-[14px] cursor-nesw-resize",
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
}[corner];
return (
<div
className={`absolute z-50 size-7 ${positionClass}`}
onMouseDown={(event) => onMouseDown(event, corner)}
/>
);
}
function ConnectionHandleDot({
side,
visible,
onMouseDown,
}: {
side: "left" | "right";
visible: boolean;
onMouseDown: (event: React.MouseEvent) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div
className={`absolute top-1/2 z-30 flex size-12 -translate-y-1/2 cursor-crosshair items-center justify-center transition-opacity duration-150 ${
side === "left" ? "-left-6" : "-right-6"
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
onMouseDown={onMouseDown}
>
<div
className="size-3 rounded-full border-2 transition-all hover:scale-125"
style={{ background: theme.node.panel, borderColor: theme.node.muted }}
/>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
stopEditing();
};
return (
<article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`}
/>
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button type="button" className="min-w-0 cursor-pointer text-left" onClick={(event) => { event.stopPropagation(); open(); }}>
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} · {project.connections.length} 线</p>
</button>
)}
</div>
<div className="mt-8 flex items-end justify-between gap-3">
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
{editing ? (
<>
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
)}
</div>
</div>
</article>
);
}
function exportProject(project: CanvasProject) {
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
link.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,23 @@
"use client";
import { useState } from "react";
import { Button, Tooltip } from "antd";
import { BookOpen } from "lucide-react";
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<>
<Tooltip title="提示词库">
<Button shape="circle" className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent" style={{ borderColor: theme.node.stroke, color: theme.node.text }} icon={<BookOpen className="size-3.5" />} onClick={() => setOpen(true)} aria-label="提示词库" />
</Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
</>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Select } from "antd";
import { cn } from "@/lib/utils";
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
type CanvasSizePickerProps = {
value: string;
className?: string;
onChange: (value: string) => void;
};
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
const selectSize = (next: string) => {
onChange(next.trim());
setSearch("");
setOpen(false);
};
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
setOpen(false);
};
window.addEventListener("pointerdown", close, true);
return () => window.removeEventListener("pointerdown", close, true);
}, [open]);
return (
<div ref={rootRef} className={className}>
<Select
showSearch
open={open}
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined}
searchValue={search}
placeholder="比例"
options={options}
popupMatchSelectWidth={false}
onOpenChange={setOpen}
onSearch={setSearch}
onChange={selectSize}
onBlur={() => {
if (search.trim()) selectSize(search);
}}
onInputKeyDown={(event) => {
if (event.key === "Enter" && search.trim()) selectSize(search);
}}
/>
</div>
);
}
@@ -0,0 +1,266 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { Button, Segmented } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Library, Moon, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
export function CanvasToolbar({
selectedCount,
canUndo,
canRedo,
backgroundMode,
onAddImage,
onAddText,
onAddConfig,
onUndo,
onRedo,
onUpload,
onDelete,
onClear,
onDeselect,
onBackgroundModeChange,
onOpenAssetLibrary,
onOpenMyAssets,
}: {
selectedCount: number;
canUndo: boolean;
canRedo: boolean;
backgroundMode: CanvasBackgroundMode;
onAddImage: () => void;
onAddText: () => void;
onAddConfig: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onOpenAssetLibrary: () => void;
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [hovered, setHovered] = useState<string | null>(null);
const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div
ref={wrapRef}
className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0"
style={dockStyle}
>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<Hand className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<Redo2 className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Type className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ImageIcon className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
<Library className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
<ToolbarButton
id="tool-style"
label="画布外观"
active={appearanceOpen}
hovered={hovered}
activeStyle={activeStyle}
hoverStyle={hoverStyle}
wrapRef={wrapRef}
onTipX={setTipX}
onHover={setHovered}
onClick={(event) => {
setPanelX(getTipX(wrapRef.current, event.currentTarget));
setAppearanceOpen((value) => !value);
}}
>
<Palette className="size-4.5" />
</ToolbarButton>
{selectedCount ? (
<>
<Divider theme={theme} />
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<Trash2 className="size-4.5" />
</ToolbarButton>
</>
) : null}
<Divider theme={theme} />
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" />
</ToolbarButton>
</div>
{appearanceOpen ? (
<div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" />
</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" />
</CanvasThemeButton>
</div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-4" /></span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-4" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" /></span> },
]}
/>
</div>
) : null}
</div>
);
}
function ToolbarButton({
id,
label,
active,
hovered,
activeStyle,
hoverStyle,
wrapRef,
onTipX,
onHover,
onClick,
disabled = false,
danger = false,
children,
}: {
id: string;
label: string;
active?: boolean;
hovered: string | null;
activeStyle?: CSSProperties;
hoverStyle: CSSProperties;
wrapRef: RefObject<HTMLDivElement | null>;
onTipX: (x: number) => void;
onHover: (id: string | null) => void;
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
disabled?: boolean;
danger?: boolean;
children: ReactNode;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<Button
type="text"
aria-label={label}
className="!h-8 !w-8 !min-w-8 !p-0"
disabled={disabled}
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
icon={children}
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }}
onMouseLeave={() => onHover(null)}
onClick={onClick}
/>
);
}
function Divider({ theme }: { theme: CanvasTheme }) {
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
}
function CanvasThemeButton({
colorTheme,
targetTheme,
onThemeChange,
children,
}: {
colorTheme: CanvasColorTheme;
targetTheme: CanvasColorTheme;
onThemeChange: (theme: CanvasColorTheme) => void;
children: ReactNode;
}) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
}
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) {
return <span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>{label}</span>;
}
function toolLabel(id: string) {
if (id === "tool-hand") return "移动/选择";
if (id === "tool-undo") return "撤销";
if (id === "tool-redo") return "重做";
if (id === "tool-text") return "文本";
if (id === "tool-image") return "图片";
if (id === "tool-config") return "生成配置";
if (id === "tool-upload") return "上传图片";
if (id === "tool-library") return "素材库";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
return "";
}
function getTipX(wrap: HTMLDivElement | null, target: HTMLElement) {
if (!wrap) return 0;
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
const box = target.getBoundingClientRect();
return box.left - wrapBox.left + box.width / 2;
}
@@ -0,0 +1,74 @@
import type { ReactNode } from "react";
import { Compass, Focus, HelpCircle } from "lucide-react";
import { useState } from "react";
import { Button, Modal, Tooltip } from "antd";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
type CanvasZoomControlsProps = {
scale: number;
onScaleChange: (scale: number) => void;
onReset: () => void;
isMiniMapOpen: boolean;
onToggleMiniMap: () => void;
};
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return (
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} icon={<Compass className="size-4" />} onClick={onToggleMiniMap} aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} />
</Tooltip>
<Tooltip title="重置视图">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
</Tooltip>
<Tooltip title="放大/缩小画布">
<input
type="range"
min="5"
max="500"
step="1"
value={Math.round(scale * 100)}
className="w-24"
style={{ accentColor: theme.node.activeStroke }}
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
aria-label="放大/缩小画布"
/>
</Tooltip>
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
{Math.round(scale * 100)}%
</span>
<Tooltip title="快捷键">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
</Tooltip>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" />
<Shortcut label="滚轮" value="缩放画布" />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
<Shortcut label="Delete / Backspace" value="删除选中" />
</div>
</Modal>
</div>
);
}
function Shortcut({ label, value }: { label: ReactNode; value: string }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-base font-medium">{label}</span>
<span className="opacity-60">{value}</span>
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "../types";
type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode;
};
export function InfiniteCanvas({
containerRef,
viewport,
backgroundMode = "lines",
onViewportChange,
onCanvasMouseDown,
onCanvasDeselect,
onContextMenu,
onDrop,
children,
}: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
startX: 0,
startY: 0,
initialX: 0,
initialY: 0,
hasMoved: false,
});
const scaleRef = useRef(viewport.k);
const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
useEffect(() => {
scaleRef.current = viewport.k;
}, [viewport.k]);
useEffect(() => () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
setIsSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
const delta = -event.deltaY;
const factor = Math.pow(1.1, delta / 100);
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
onViewportChange({
x: mouseX - worldX * newScale,
y: mouseY - worldY * newScale,
k: newScale,
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-connection-create-menu]")) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
isPanning: true,
startX: event.clientX,
startY: event.clientY,
initialX: viewport.x,
initialY: viewport.y,
hasMoved: false,
};
document.body.style.cursor = "grabbing";
return;
}
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
event.preventDefault();
}
};
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
if (!panState.current.isPanning) return;
const dx = event.clientX - panState.current.startX;
const dy = event.clientY - panState.current.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panState.current.hasMoved = true;
}
nextViewportRef.current = {
x: panState.current.initialX + dx,
y: panState.current.initialY + dy,
k: scaleRef.current,
};
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
});
};
const handlePointerUp = () => {
if (!panState.current.isPanning) return;
if (!panState.current.hasMoved) {
onCanvasDeselect?.();
}
panState.current.isPanning = false;
document.body.style.cursor = "default";
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [onCanvasDeselect, onViewportChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
return (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown}
onWheel={handleWheel}
onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<CanvasGrid viewport={viewport} mode={backgroundMode} />
<div
className="absolute origin-top-left"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{children}
</div>
</div>
);
}
function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: CanvasBackgroundMode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (mode === "blank") return null;
const gridSize = 48 * viewport.k;
const x = viewport.x % gridSize;
const y = viewport.y % gridSize;
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
const backgroundImage = mode === "dots"
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)`
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
return (
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
backgroundImage,
backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${x}px ${y}px`,
}}
/>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { CanvasNodeType } from "./types";
import type { CanvasNodeMetadata } from "./types";
type CanvasNodeSpec = {
width: number;
height: number;
title: string;
metadata?: CanvasNodeMetadata;
};
export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
export const NODE_SPECS = {
[CanvasNodeType.Image]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Image],
metadata: { content: "", status: "idle" },
},
[CanvasNodeType.Text]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Text],
metadata: { content: "", status: "idle", fontSize: 14 },
},
[CanvasNodeType.Config]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
metadata: { content: "", status: "idle", generationMode: "image" },
},
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
export function getNodeSpec(type: CanvasNodeType) {
return NODE_SPECS[type];
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { App, Button } from "antd";
import { FileUp, Plus } from "lucide-react";
import { CanvasDeleteProjectsDialog } from "./components/canvas-delete-projects-dialog";
import { CanvasProjectCard } from "./components/canvas-project-card";
import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
type CanvasExportFile = {
app: "infinite-canvas";
version: number;
exportedAt: string;
project: CanvasProject;
};
export default function CanvasPage() {
const { message } = App.useApp();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const createProject = useCanvasStore((state) => state.createProject);
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const enterProject = (id: string) => {
router.push(`/canvas/${id}`);
};
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
const importCanvas = async (file?: File) => {
if (!file) return;
try {
const data = JSON.parse(await file.text()) as CanvasExportFile;
enterProject(importProject(data.project));
message.success("画布已导入");
} catch {
message.error("导入失败,请选择有效的 JSON 文件");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
<div>
<p className="text-xs text-stone-500"></p>
<h1 className="mt-3 text-3xl font-semibold"></h1>
</div>
<div className="flex items-center gap-2">
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}></Button> : null}
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}></Button> : null}
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}></Button>
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}></Button>
</div>
</header>
{!hydrated ? (
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section>
) : projects.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<CanvasProjectCard key={project.id} project={project} />
))}
</div>
) : (
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
<h2 className="text-xl font-medium"></h2>
<p className="mt-3 text-sm text-stone-500">线</p>
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}></Button>
</section>
)}
</div>
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog />
</main>
);
}
@@ -0,0 +1,125 @@
import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { createId } from "@/lib/id";
import { localForageStorage } from "@/lib/localforage-storage";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "../types";
export type CanvasProject = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
backgroundMode: CanvasBackgroundMode;
viewport: ViewportTransform;
};
type CanvasStore = {
hydrated: boolean;
projects: CanvasProject[];
createProject: (title?: string) => string;
importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
const CANVAS_STORE_KEY = "infinite-canvas:canvas_store";
type PersistedCanvasState = Pick<CanvasStore, "projects">;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let queuedPersistState: PersistedCanvasState | null = null;
const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
queuedPersistState = parsed.state as PersistedCanvasState;
return parsed;
},
setItem: (name, value) => {
const nextState = value.state as PersistedCanvasState;
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
queuedPersistState = nextState;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void localForageStorage.setItem(name, JSON.stringify(value));
}, 400);
},
removeItem: (name) => localForageStorage.removeItem(name),
};
export const useCanvasStore = create<CanvasStore>()(
persist(
(set, get) => ({
hydrated: false,
projects: [],
createProject: (title = "未命名画布") => {
const now = new Date().toISOString();
const id = createId();
const project: CanvasProject = {
id,
title,
createdAt: now,
updatedAt: now,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "lines",
viewport: initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return id;
},
importProject: (source) => {
const now = new Date().toISOString();
const project: CanvasProject = {
id: createId(),
title: source.title || "导入画布",
createdAt: source.createdAt || now,
updatedAt: now,
nodes: source.nodes || [],
connections: source.connections || [],
chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null,
backgroundMode: source.backgroundMode || "lines",
viewport: source.viewport || initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return project.id;
},
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
renameProject: (id, title) => set((state) => ({
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project),
})),
deleteProjects: (ids) => set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
updateProject: (id, patch) => set((state) => ({
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project),
})),
}),
{
name: CANVAS_STORE_KEY,
storage: canvasStorage,
partialize: (state) => ({
projects: state.projects,
}) as StorageValue<CanvasStore>["state"],
onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
);
@@ -0,0 +1,27 @@
import { create } from "zustand";
type CanvasUiStore = {
editingProjectId: string | null;
editingProjectTitle: string;
selectedProjectIds: string[];
deleteProjectIds: string[];
startEditingProject: (id: string, title: string) => void;
setEditingProjectTitle: (title: string) => void;
stopEditingProject: () => void;
toggleSelectedProjectId: (id: string, selected: boolean) => void;
setDeleteProjectIds: (ids: string[]) => void;
removeSelectedProjectIds: (ids: string[]) => void;
};
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
editingProjectId: null,
editingProjectTitle: "",
selectedProjectIds: [],
deleteProjectIds: [],
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
stopEditingProject: () => set({ editingProjectId: null }),
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
}));
+115
View File
@@ -0,0 +1,115 @@
export type Position = {
x: number;
y: number;
};
export type ViewportTransform = {
x: number;
y: number;
k: number;
};
export enum CanvasNodeType {
Image = "image",
Text = "text",
Config = "config",
}
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasGenerationMode = "text" | "image";
export type CanvasNodeMetadata = {
content?: string;
prompt?: string;
status?: CanvasNodeStatus;
errorDetails?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;
model?: string;
size?: string;
count?: number;
naturalWidth?: number;
naturalHeight?: number;
freeResize?: boolean;
isBatchRoot?: boolean;
batchRootId?: string;
batchChildIds?: string[];
primaryImageId?: string;
imageBatchExpanded?: boolean;
inputOrder?: string[];
storageKey?: string;
mimeType?: string;
bytes?: number;
};
export type CanvasNodeData = {
id: string;
type: CanvasNodeType;
title: string;
position: Position;
width: number;
height: number;
metadata?: CanvasNodeMetadata;
};
export type CanvasConnection = {
id: string;
fromNodeId: string;
toNodeId: string;
};
export type CanvasAssistantReference = {
id: string;
type: CanvasNodeType;
title: string;
dataUrl?: string;
storageKey?: string;
text?: string;
};
export type CanvasAssistantImage = {
id: string;
dataUrl: string;
storageKey?: string;
prompt: string;
};
export type CanvasAssistantMessage = {
id: string;
role: "user" | "assistant";
mode: "ask" | "image";
text: string;
isLoading?: boolean;
references?: CanvasAssistantReference[];
images?: CanvasAssistantImage[];
};
export type CanvasAssistantSession = {
id: string;
title: string;
messages: CanvasAssistantMessage[];
createdAt: string;
updatedAt: string;
};
export type ConnectionHandle = {
nodeId: string;
handleType: "source" | "target";
};
export type SelectionBox = {
startWorldX: number;
startWorldY: number;
currentWorldX: number;
currentWorldY: number;
additive: boolean;
initialSelectedNodeIds: string[];
};
export type ContextMenuState =
{
type: "node";
x: number;
y: number;
nodeId: string;
};
@@ -0,0 +1,90 @@
"use client";
export type ImageCropRect = {
x: number;
y: number;
width: number;
height: number;
};
export type ImageAngleTransform = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl);
if (crop) {
return drawCrop(
image,
Math.floor(crop.x * image.width),
Math.floor(crop.y * image.height),
Math.ceil(crop.width * image.width),
Math.ceil(crop.height * image.height),
);
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d");
if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height);
const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45;
context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore();
if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL("image/png");
}
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d");
if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.src = dataUrl;
});
}
+619
View File
@@ -0,0 +1,619 @@
"use client";
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Image, Input, InputNumber, Modal, Select, Tag, Typography } from "antd";
import localforage from "localforage";
import { ModelPicker } from "@/components/model-picker";
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
import type { AiConfig } from "@/lib/ai-config";
import { createId } from "@/lib/id";
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { requestEdit, requestGeneration } from "@/services/api/image";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import type { ReferenceImage } from "@/types/image";
type GeneratedImage = {
id: string;
dataUrl: string;
durationMs: number;
width: number;
height: number;
bytes: number;
};
type GenerationResult = {
id: string;
status: "pending" | "success" | "failed";
image?: GeneratedImage;
error?: string;
};
type GenerationLog = {
id: string;
createdAt: number;
title: string;
time: string;
model: string;
durationMs: number;
successCount: number;
failCount: number;
imageCount: number;
size: string;
quality: string;
status: "成功" | "失败";
thumbnails: string[];
};
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
const qualityOptions = ["auto", "low", "medium", "high"].map((value) => ({ label: value, value }));
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
export default function ImagePage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const config = useAiConfigStore((state) => state.config);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const addAsset = useAssetStore((state) => state.addAsset);
const [prompt, setPrompt] = useState("");
const [references, setReferences] = useState<ReferenceImage[]>([]);
const [results, setResults] = useState<GenerationResult[]>([]);
const [logs, setLogs] = useState<GenerationLog[]>([]);
const [running, setRunning] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [startedAt, setStartedAt] = useState(0);
const [elapsedMs, setElapsedMs] = useState(0);
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const model = config.imageModel || config.model;
const canGenerate = Boolean(prompt.trim());
const generationCount = Math.max(1, Math.min(10, Number(config.count) || 1));
useEffect(() => {
if (!running || !startedAt) return;
const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000);
return () => window.clearInterval(timer);
}, [running, startedAt]);
useEffect(() => {
void refreshLogs();
}, []);
const addReferences = async (files?: FileList | null) => {
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
const nextReferences = await Promise.all(imageFiles.map(async (file) => {
const image = await uploadImage(file);
return { id: createId(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
}));
setReferences((value) => [...value, ...nextReferences]);
};
const addReferencesFromClipboard = async () => {
try {
const items = await navigator.clipboard.read();
const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type))));
if (!blobs.length) {
message.error("剪切板里没有可读取的图片");
return;
}
const nextReferences = await Promise.all(blobs.map(async (blob, index) => {
const image = await uploadImage(blob);
return { id: createId(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
}));
setReferences((value) => [...value, ...nextReferences]);
message.success(`已读取 ${nextReferences.length} 张参考图`);
} catch {
message.error("剪切板里没有可读取的图片");
}
};
const generate = async () => {
const text = prompt.trim();
if (!text) {
message.error("请输入生图提示词");
return;
}
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
message.warning("请先完成配置");
openConfigDialog(true);
return;
}
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
setElapsedMs(0);
setRunning(true);
setPreviewLog(null);
setResults(Array.from({ length: generationCount }, () => ({ id: createId(), status: "pending" })));
const batchStartedAt = performance.now();
setStartedAt(batchStartedAt);
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
const result = await Promise.allSettled(tasks);
const successImages = result
.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled")
.map((item) => item.value);
const successCount = successImages.length;
const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
try {
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) }));
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
} finally {
setRunning(false);
}
};
const downloadImage = (image: GeneratedImage, index: number) => {
const link = document.createElement("a");
link.href = image.dataUrl;
link.download = `image-${index + 1}.png`;
link.click();
};
const addResultToReferences = async (image: GeneratedImage, index: number) => {
const stored = await uploadImage(image.dataUrl);
setReferences((value) => [...value, { id: createId(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
message.success("已加入参考图");
};
const saveResultToAssets = async (image: GeneratedImage, index: number) => {
const stored = await uploadImage(image.dataUrl);
addAsset({
kind: "image",
title: `生成结果 ${index + 1}`,
coverUrl: stored.url,
tags: [],
source: "生图工作台",
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
metadata: { source: "image-page", prompt },
});
message.success("已加入我的素材");
};
const insertPickedAsset = async (payload: InsertAssetPayload) => {
if (payload.kind === "text") {
setPrompt(payload.content);
} else {
const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: createId(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
}
setAssetPickerOpen(false);
};
const createSession = () => {
setPrompt("");
setReferences([]);
setResults([]);
setElapsedMs(0);
setStartedAt(0);
setSelectedLogIds([]);
setPreviewLog(null);
};
const deleteSelectedLogs = () => {
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null);
setResults([]);
}
setSelectedLogIds([]);
setDeleteConfirmOpen(false);
};
const saveLog = (log: GenerationLog) => {
void logStore.setItem(log.id, log).then(refreshLogs);
};
const refreshLogs = async () => setLogs(await readStoredLogs());
const previewGenerationLog = async (log: GenerationLog) => {
setPreviewLog(log);
setLogsOpen(false);
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => {
const meta = await readImageMeta(dataUrl);
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
}));
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
};
const buildRequestSnapshot = () => {
const text = prompt.trim();
if (!text) {
message.error("请输入生图提示词");
return null;
}
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
message.warning("请先完成配置");
openConfigDialog(true);
return null;
}
return { text, config: { ...config, model, count: "1" }, references: [...references] };
};
const runGenerationSlot = async (index: number, snapshot: { text: string; config: AiConfig; references: ReferenceImage[] }) => {
const itemStartedAt = performance.now();
try {
const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text);
const image = result[0];
if (!image) throw new Error("接口没有返回图片");
const meta = await readImageMeta(image.dataUrl);
const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) };
setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage }));
return nextImage;
} catch (error) {
setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : "生成失败" }));
throw error;
}
};
const retryResult = (index: number) => {
const snapshot = buildRequestSnapshot();
if (!snapshot) return;
setPreviewLog(null);
setResults((value) => updateResultAt(value, index, { status: "pending", error: undefined, image: undefined }));
void runGenerationSlot(index, snapshot).catch(() => {});
};
return (
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
</aside>
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
<div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
</div>
<div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}></Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</div>
</div>
</div>
<div className="mt-6 space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}></Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}></Button>
</div>
</div>
<Input.TextArea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={7}
placeholder="描述画面主体、风格、构图、光线和用途"
/>
</div>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}></Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}></Button>
</div>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault();
event.currentTarget.scrollLeft += event.deltaY;
}}>
{references.map((item) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
<Trash2 className="size-3.5" />
</button>
</div>
))}
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"></div> : null}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {config.size} · {config.quality}</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</div>
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</div>
<div className="mt-auto pt-6">
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
</Button>
</div>
</div>
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold"></h2>
</div>
{running ? <Tag className="m-0 px-2 py-1"> {formatDuration(elapsedMs)}</Tag> : null}
</div>
{results.length ? (
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{results.map((result, index) => (
result.status === "success" && result.image ? (
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
) : result.status === "failed" ? (
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
) : (
<PendingImageCard key={result.id} />
)
))}
</div>
) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
<ImagePlus className="mb-4 size-11 text-stone-400" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成图片" />
</div>
)}
</div>
</section>
</main>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => {
void addReferences(event.target.files);
event.target.value = "";
}} />
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
</Drawer>
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3">
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedLogIds.length}
</Modal>
</div>
);
}
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
return (
<>
<label className="col-span-2 block min-w-0 sm:col-span-1">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<InputNumber className="canvas-control-number !w-full" min={1} max={10} value={Number(config.count) || 1} onChange={(value) => updateConfig("count", String(value || 1))} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<AutoComplete className="canvas-control-select w-full" value={config.size} options={sizeOptions} placeholder="例如 1:1、3:2" onChange={(value) => updateConfig("size", value)} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base"></span>
<Select className="canvas-control-select w-full" value={config.quality} options={qualityOptions} onChange={(value) => updateConfig("quality", value)} />
</label>
</>
);
}
function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void }) {
return (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
<span>{image.width}x{image.height}</span>
<span>{formatBytes(image.bytes)}</span>
<span>{formatDuration(image.durationMs)}</span>
</div>
<div className="flex shrink-0 gap-1">
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}></Button>
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}></Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}></Button>
</div>
</div>
</div>
);
}
function PendingImageCard() {
return (
<div className="relative aspect-square overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<LoaderCircle className="size-6 animate-spin" />
<span></span>
</div>
</div>
);
}
function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
<div className="flex aspect-square flex-col items-center justify-center gap-3 p-5 text-center">
<div className="text-sm font-medium text-red-600 dark:text-red-300"></div>
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
{error}
</Typography.Paragraph>
</div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}></Button>
</div>
</div>
);
}
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
return results.map((item, itemIndex) => itemIndex === index ? { ...item, ...next } : item);
}
function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void }) {
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
return (
<>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold"></h2>
</div>
<Tag className="m-0">{logs.length}</Tag>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}></Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}></Button>
</div>
<div className="space-y-3">
{logs.map((log) => (
<LogCard
key={log.id}
log={log}
selected={selectedLogIds.includes(log.id)}
active={activeLogId === log.id}
onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))}
onClick={() => onPreviewLog(log)}
/>
))}
{!logs.length ? (
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
</div>
) : null}
</div>
</>
);
}
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
return (
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
{log.thumbnails?.length ? (
<div className="mt-2 flex gap-1 overflow-hidden">
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)}
</div>
) : null}
</div>
</div>
<div className="grid justify-items-end gap-2">
<div className="flex gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue"> {log.successCount ?? log.imageCount}</Tag>
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red"> {log.failCount}</Tag> : null}
</div>
<div className="flex flex-wrap justify-end gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} </Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag>
</div>
<div className="flex justify-end">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
</div>
</div>
</div>
</button>
);
}
async function readStoredLogs() {
if (typeof window === "undefined") return [];
try {
const legacyValue = window.localStorage.getItem(LOG_STORE_KEY);
if (legacyValue) {
await Promise.all((JSON.parse(legacyValue) as GenerationLog[]).map((log) => logStore.setItem(log.id, normalizeLog(log))));
window.localStorage.removeItem(LOG_STORE_KEY);
}
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
await Promise.all(legacyKeys.map(async (key) => {
try {
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
await logStore.setItem(log.id, log);
window.localStorage.removeItem(key);
} catch {}
}));
const logs: GenerationLog[] = [];
await logStore.iterate<GenerationLog, void>((value) => {
logs.push(normalizeLog(value));
});
return logs
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch {
return [];
}
}
function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
return {
id: log.id || createId(),
createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
model: log.model || "",
durationMs: log.durationMs || 0,
successCount: log.successCount ?? log.imageCount ?? 0,
failCount: log.failCount || 0,
imageCount: log.imageCount || log.successCount || 0,
size: log.size || "",
quality: log.quality || "",
status: log.status || "成功",
thumbnails: log.thumbnails || [],
};
}
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog {
return {
id: createId(),
createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名",
time: new Date().toLocaleString("zh-CN", { hour12: false }),
model,
durationMs,
successCount,
failCount,
imageCount: Number(config.count) || successCount,
size: config.size,
quality: config.quality,
status,
thumbnails,
};
}
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { AppShell } from "@/components/app-shell";
export default function UserLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { App, Button, Form, Input } from "antd";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { useUserStore } from "@/stores/use-user-store";
type LoginFormValues = {
username: string;
password: string;
confirmPassword?: string;
};
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}
function LoginContent() {
const { message } = App.useApp();
const router = useRouter();
const searchParams = useSearchParams();
const login = useUserStore((state) => state.login);
const isLoading = useUserStore((state) => state.isLoading);
const redirect = searchParams.get("redirect") || "/";
const submit = async (values: LoginFormValues) => {
try {
const user = await login({ username: values.username, password: values.password });
message.success("登录成功");
router.replace(redirect.startsWith("/") ? redirect : "/");
router.refresh();
if (user.role !== "admin") router.replace("/");
} catch (error) {
message.error(error instanceof Error ? error.message : "登录失败");
}
};
return (
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<section className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<span
className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
aria-label="无限画布"
/>
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p>
</div>
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入用户名" }]}>
<Input prefix={<UserOutlined />} autoComplete="username" />
</Form.Item>
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
</Form.Item>
<Button block type="primary" htmlType="submit" loading={isLoading}>
</Button>
</Form>
</section>
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { ArrowRight } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { App, Button, Image, Tag } from "antd";
import { fetchPrompts, type Prompt } from "@/services/api/prompts";
import { navigationTools } from "@/lib/navigation-tools";
import { cn } from "@/lib/utils";
function Highlighter({
action,
color,
children,
}: {
action: "highlight" | "underline";
color: string;
children: ReactNode;
}) {
return (
<span className="relative inline-block px-1">
{action === "highlight" ? (
<span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
) : (
<span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
)}
<span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
</span>
);
}
export default function IndexPage() {
const { message } = App.useApp();
const [primaryTool] = navigationTools;
const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]);
const [previewIndex, setPreviewIndex] = useState(0);
const [previewOpen, setPreviewOpen] = useState(false);
useEffect(() => {
void fetchPrompts({ pageSize: 12 }).then((data) => setPromptShowcase(data.items)).catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
}, [message]);
return (
<main className="relative h-full overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] text-stone-950 dark:bg-[radial-gradient(rgba(245,245,244,.18)_1px,transparent_1px)] dark:text-stone-100">
<section className="relative mx-auto min-h-[calc(100vh-4rem)] max-w-7xl overflow-hidden px-6">
<div className="pointer-events-none absolute left-[15%] top-24 size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">
</h1>
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
<Highlighter action="underline" color="#FF9800"></Highlighter><Highlighter action="highlight" color="#87CEFA"></Highlighter>
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
使
</Button>
<Button size="large" href="/canvas">
</Button>
</div>
</div>
<section className="relative mx-auto mb-20 max-w-6xl border-t border-stone-200 pt-12 dark:border-stone-800">
<div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div />
<div className="max-w-2xl text-center">
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h2>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">
</p>
</div>
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
</Button>
</div>
<div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
{promptShowcase.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => {
setPreviewIndex(index);
setPreviewOpen(true);
}}
className={cn(
"group relative cursor-pointer overflow-hidden border border-stone-200 bg-stone-100 text-left dark:border-stone-800 dark:bg-stone-900",
index === 0 && "md:col-span-2 md:row-span-2",
index === 3 && "md:col-span-2",
)}
>
<img src={item.coverUrl} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent p-4 text-white">
<div className="mb-2 flex flex-wrap gap-1.5">
{item.tags.slice(0, 2).map((tag) => (
<Tag key={tag} variant="filled" className="m-0 bg-white/15 text-[11px] text-white backdrop-blur">
{tag}
</Tag>
))}
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-white/75">{item.prompt}</p>
</div>
</button>
))}
</div>
</section>
</section>
<Image.PreviewGroup
preview={{
open: previewOpen,
current: previewIndex,
onOpenChange: setPreviewOpen,
onChange: setPreviewIndex,
}}
>
<div className="hidden">
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)}
</div>
</Image.PreviewGroup>
</main>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { FolderPlus, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } from "antd";
import { PromptCard } from "@/components/prompts/prompt-card";
import { PromptDetailDialog } from "@/components/prompts/prompt-detail-dialog";
import { usePromptList } from "@/components/prompts/use-prompt-list";
import { cn } from "@/lib/utils";
import { useAssetStore } from "@/stores/use-asset-store";
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
export default function PromptsPage() {
const { message } = App.useApp();
const [titleKeyword, setTitleKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}
}, [message, query.error, query.isError]);
const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const copyPrompt = async (prompt: string) => {
await navigator.clipboard.writeText(prompt);
message.success("提示词已复制");
};
const savePromptAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的素材");
};
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
void query.fetchNextPage();
}
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</div>
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? (
<>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
</div>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategoryOptions.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)} className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</>
) : null}
</div>
{!query.isLoading ? (
<div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{promptItems.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => void copyPrompt(item.prompt)} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} />
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</div>
) : null}
</main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => void copyPrompt(prompt)} onSaveAsset={savePromptAsset} />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+443
View File
@@ -0,0 +1,443 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--radius: 0.625rem;
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--input: oklch(0.922 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--admin-card-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--input: oklch(1 0 0 / 15%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--admin-card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
@apply bg-background text-foreground;
}
textarea {
resize: none;
}
textarea::-webkit-resizer {
display: none;
}
html {
@apply font-sans;
}
}
@layer utilities {
.ai-title-aurora {
background:
linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: ai-title-aurora 7s ease-in-out infinite;
}
.dark .ai-title-aurora {
background:
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
}
@keyframes ai-title-aurora {
0%, 100% {
background-position: 0% 50%;
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
}
50% {
background-position: 100% 50%;
filter: drop-shadow(0 12px 28px rgba(28, 25, 23, 0.08));
}
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.prompt-filter-tag.ant-tag-checkable {
margin: 0;
border: 1px solid transparent;
border-radius: 6px;
padding: 5px 10px;
color: #57534e;
font-size: 14px;
line-height: 1;
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(28, 25, 23, 0.06);
color: #1c1917;
}
.prompt-filter-tag.ant-tag-checkable.is-active,
.prompt-filter-tag.ant-tag-checkable-checked {
background: #111111;
border-color: #111111;
color: #ffffff;
font-weight: 600;
}
.dark .prompt-filter-tag.ant-tag-checkable {
color: #e7e5e4;
}
.dark .prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.dark .prompt-filter-tag.ant-tag-checkable.is-active,
.dark .prompt-filter-tag.ant-tag-checkable-checked {
background: rgba(250, 250, 249, 0.96);
border-color: rgba(255, 255, 255, 0.88);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 18px rgba(0, 0, 0, 0.22);
color: #0c0a09 !important;
font-weight: 700;
}
.thin-scrollbar {
scrollbar-color: rgba(120, 113, 108, 0.45) transparent;
scrollbar-width: thin;
}
.thin-scrollbar::-webkit-scrollbar {
height: 4px;
width: 4px;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.45);
border-radius: 999px;
}
.hover-scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.hover-scrollbar:hover {
scrollbar-color: rgba(120, 113, 108, 0.5) transparent;
}
.hover-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.hover-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-track {
background: rgba(120, 113, 108, 0.12);
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.5);
}
.hover-scrollbar-hint {
position: relative;
}
.hover-scrollbar-hint::after {
background: rgba(120, 113, 108, 0.42);
border-radius: 999px;
bottom: 4px;
content: "";
height: 3px;
left: 12px;
opacity: 0;
pointer-events: none;
position: absolute;
right: 12px;
transition: opacity 0.18s ease;
}
.hover-scrollbar-hint:hover::after {
opacity: 1;
}
.canvas-control-select.ant-select .ant-select-selector,
.canvas-control-number.ant-input-number {
border-color: rgba(120, 113, 108, 0.32) !important;
box-shadow: none !important;
}
.canvas-compact-control.canvas-control-select.ant-select {
height: 100%;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selector,
.canvas-compact-control.canvas-control-number.ant-input-number {
height: 100% !important;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder {
align-items: center;
display: flex;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-search-input,
.canvas-compact-control.canvas-control-number.ant-input-number .ant-input-number-input {
height: 100% !important;
line-height: 1 !important;
}
.canvas-control-select.ant-select-focused .ant-select-selector,
.canvas-control-number.ant-input-number-focused {
border-color: #a8a29e !important;
}
.dark .canvas-control-select.ant-select .ant-select-selector,
.dark .canvas-control-number.ant-input-number {
border-color: rgba(214, 211, 209, 0.2) !important;
}
.dark .canvas-control-select.ant-select-focused .ant-select-selector,
.dark .canvas-control-number.ant-input-number-focused {
border-color: #78716c !important;
}
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
align-items: center;
display: flex;
height: 30px;
justify-content: center;
padding-inline: 9px;
}
.canvas-image-settings-popover .ant-popover-inner {
border-radius: 18px;
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
padding: 18px;
}
.canvas-image-settings-popover .ant-segmented {
border-radius: 999px;
padding: 3px;
}
.canvas-image-settings-popover .ant-segmented-item {
border-radius: 999px;
}
.dark .canvas-image-settings-popover .ant-popover-inner {
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.45);
}
.canvas-node-info-modal .ant-modal-header {
padding-right: 0;
}
.canvas-node-info-modal .ant-modal-title {
line-height: 1;
}
.canvas-node-info-modal .ant-modal-close {
top: 21px;
}
.admin-layout .ant-layout-sider {
border-right: 1px solid var(--border);
}
.admin-layout .ant-layout-header {
border-bottom: 1px solid var(--border);
}
.admin-layout .ant-layout-sider-children > .ant-flex:first-child {
border-bottom: 1px solid var(--border);
}
.admin-logo {
background: currentColor;
display: inline-block;
height: 32px;
mask: url(/logo.svg) center / contain no-repeat;
width: 32px;
-webkit-mask: url(/logo.svg) center / contain no-repeat;
}
.admin-layout .ant-layout-sider-children > .ant-flex:last-child {
border-top: 1px solid var(--border);
background: var(--card);
}
.admin-layout .ant-card,
.admin-layout .ant-pro-card {
box-shadow: var(--admin-card-shadow);
}
.admin-layout .ant-table-cell .ant-btn-icon-only.ant-btn-sm {
height: 26px;
width: 26px;
}
@keyframes canvas-batch-child-in {
from {
opacity: 0;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
}
72% {
opacity: 1;
transform: translate(0, 0) rotate(-1deg) scale(1.015);
}
to {
opacity: 1;
transform: translate(0, 0) rotate(0) scale(1);
}
}
@keyframes canvas-batch-child-out {
from {
opacity: 1;
transform: translate(0, 0) rotate(0) scale(1);
}
to {
opacity: 0;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
}
}
}
::view-transition-old(root), ::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { AntThemeProvider } from "@/components/ant-theme-provider";
import { QueryProvider } from "@/components/query-provider";
import { ThemeSync } from "@/components/theme-sync";
import { UserSessionSync } from "@/components/user-session-sync";
import "antd/dist/reset.css";
import "./globals.css";
export const metadata: Metadata = {
title: "无限画布",
description: "一个无限画布创作工具",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning className="font-sans">
<body
className="bg-background text-foreground antialiased"
style={{
fontFamily:
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
}}
>
<script
dangerouslySetInnerHTML={{
__html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`,
}}
/>
<ThemeSync />
<AntThemeProvider>
<QueryProvider>
<UserSessionSync />
{children}
</QueryProvider>
</AntThemeProvider>
</body>
</html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Home, LogIn } from "lucide-react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
export default function NotFound() {
return (
<AppShell>
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
<section className="w-full max-w-md text-center">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
404
</div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link href="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
<Home className="size-4" />
</Link>
<Link href="/login" className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800">
<LogIn className="size-4" />
</Link>
</div>
</section>
</main>
</AppShell>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import type { ReactNode } from "react";
import { ProConfigProvider } from "@ant-design/pro-components";
import { App, ConfigProvider, theme as antdTheme } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useThemeStore } from "@/stores/use-theme-store";
export function AntThemeProvider({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const colors = dark
? {
bg: "#1f1d1a",
layout: "#181715",
panel: "#24211e",
elevated: "#292524",
fill: "#322e29",
fillHover: "#3a3631",
border: "#44403c",
borderSoft: "rgba(214, 211, 209, 0.18)",
text: "#f5f5f4",
textSecondary: "#d6d3d1",
textTertiary: "#a8a29e",
primary: "#f5f5f4",
primaryText: "#1c1917",
menuSelected: "#3a3631",
tableHeader: "#2b2521",
}
: {
bg: "#fbfaf7",
layout: "#f4f2ed",
panel: "#ffffff",
elevated: "#ffffff",
fill: "#f5f5f4",
fillHover: "#e7e5df",
border: "#d6d3ca",
borderSoft: "rgba(87, 83, 78, 0.18)",
text: "#292524",
textSecondary: "#57534e",
textTertiary: "#78716c",
primary: "#111111",
primaryText: "#ffffff",
menuSelected: "#f5f5f4",
tableHeader: "#f8fafc",
};
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
colorPrimary: colors.primary,
colorPrimaryHover: dark ? "#ffffff" : "#2a2a2a",
colorPrimaryActive: dark ? "#e7e5e4" : "#000000",
colorInfo: colors.primary,
colorBgBase: colors.bg,
colorBgLayout: colors.layout,
colorBgContainer: colors.panel,
colorBgElevated: colors.elevated,
colorFill: colors.fill,
colorFillSecondary: colors.fill,
colorFillTertiary: colors.fillHover,
colorFillQuaternary: dark ? "rgba(245, 245, 244, 0.08)" : "rgba(28, 25, 23, 0.04)",
colorBorder: colors.border,
colorBorderSecondary: colors.borderSoft,
colorSplit: colors.borderSoft,
colorText: colors.text,
colorTextBase: colors.text,
colorTextSecondary: colors.textSecondary,
colorTextTertiary: colors.textTertiary,
colorTextQuaternary: dark ? "#78716c" : "#a8a29e",
colorIcon: colors.textSecondary,
colorIconHover: colors.text,
colorLink: colors.text,
colorLinkHover: colors.text,
colorBgSpotlight: dark ? "#f5f5f4" : "#1c1917",
colorTextLightSolid: dark ? "#1c1917" : "#ffffff",
colorError: "#ff4d4f",
colorErrorHover: "#ff7875",
colorErrorActive: "#d9363e",
colorWarning: "#faad14",
colorBgMask: dark ? "rgba(0, 0, 0, 0.62)" : "rgba(28, 25, 23, 0.35)",
borderRadius: 8,
borderRadiusLG: 12,
boxShadow: dark ? "0 18px 48px rgba(0, 0, 0, 0.46)" : "0 18px 48px rgba(41, 37, 36, 0.16)",
boxShadowSecondary: dark ? "0 0 0 1px rgba(255,255,255,.06)" : "0 1px 2px rgba(15,23,42,.04)",
fontFamily: '"SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
},
components: {
Button: {
primaryColor: colors.primaryText,
defaultColor: colors.text,
defaultBg: dark ? "#1f1d1a" : "#ffffff",
defaultBorderColor: colors.border,
defaultHoverColor: colors.text,
defaultHoverBg: colors.fillHover,
defaultHoverBorderColor: dark ? "#78716c" : "#a8a29e",
dangerColor: "#ffffff",
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
},
Modal: {
contentBg: colors.elevated,
headerBg: colors.elevated,
footerBg: colors.elevated,
titleColor: colors.text,
},
Menu: {
popupBg: colors.elevated,
itemBg: colors.panel,
itemColor: colors.textSecondary,
itemHoverBg: colors.fillHover,
itemHoverColor: colors.text,
itemActiveBg: colors.menuSelected,
itemSelectedBg: colors.menuSelected,
itemSelectedColor: dark ? "#ffffff" : colors.text,
darkItemBg: colors.panel,
darkItemColor: colors.textSecondary,
darkItemHoverBg: colors.fillHover,
darkItemHoverColor: colors.text,
darkItemSelectedBg: colors.menuSelected,
darkItemSelectedColor: "#ffffff",
},
Layout: {
bodyBg: colors.layout,
headerBg: colors.panel,
headerColor: colors.text,
lightSiderBg: colors.panel,
siderBg: colors.panel,
},
Card: {
bodyPadding: 24,
headerBg: colors.panel,
headerHeight: 56,
},
Table: {
borderColor: colors.borderSoft,
cellPaddingBlockMD: 14,
headerBg: colors.tableHeader,
headerColor: colors.text,
rowHoverBg: colors.fill,
},
Segmented: {
trackBg: colors.fill,
itemSelectedBg: colors.primary,
itemSelectedColor: colors.primaryText,
},
Select: {
selectorBg: dark ? "#1f1d1a" : "#ffffff",
optionActiveBg: colors.fillHover,
optionSelectedBg: dark ? "#3f3a35" : "#f5f5f4",
optionSelectedColor: colors.text,
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeOutlineColor: "transparent",
},
Input: {
activeBg: dark ? "#1f1d1a" : "#ffffff",
hoverBg: dark ? "#1f1d1a" : "#ffffff",
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeShadow: "none",
},
InputNumber: {
activeBg: dark ? "#1f1d1a" : "#ffffff",
hoverBg: dark ? "#1f1d1a" : "#ffffff",
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeShadow: "none",
},
Tabs: {
itemColor: colors.textSecondary,
itemSelectedColor: colors.text,
itemHoverColor: colors.text,
inkBarColor: colors.text,
},
Checkbox: {
colorPrimary: colors.primary,
colorPrimaryHover: colors.primary,
},
},
}}
>
<ProConfigProvider dark={dark}>
<App>{children}</App>
</ProConfigProvider>
</ConfigProvider>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { AppTopNav } from "@/components/app-top-nav";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
return <MainAppShell pathname={pathname}>{children}</MainAppShell>;
}
function MainAppShell({ pathname, children }: { pathname: string; children: ReactNode }) {
const config = useAiConfigStore((state) => state.config);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
const isCanvasDetail = /^\/canvas\/[^/]+/.test(pathname);
return (
<ShellFrame>
<AppTopNav activeToolSlug={activeToolSlug} config={config} onConfigChange={updateConfig} hideHeader={isCanvasDetail} />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</ShellFrame>
);
}
function ShellFrame({ children }: { children: ReactNode }) {
return <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">{children}</div>;
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { LogOut, Menu, Settings2, Shield } from "lucide-react";
import Link from "next/link";
import { App, Button, Drawer, Form, Input, Modal } from "antd";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { ModelPicker } from "@/components/model-picker";
import { GitHubLink } from "@/components/github-link";
import { UserStatusActions } from "@/components/user-status-actions";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import type { AiConfig } from "@/lib/ai-config";
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
import { fetchImageModels } from "@/services/api/image";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { cn } from "@/lib/utils";
import { useState } from "react";
type AppTopNavProps = {
activeToolSlug?: NavigationToolSlug;
config: AiConfig;
onConfigChange: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
hideHeader?: boolean;
};
export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = false }: AppTopNavProps) {
const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
const isConfigOpen = useConfigDialogStore((state) => state.isOpen);
const shouldPromptContinue = useConfigDialogStore((state) => state.shouldPromptContinue);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const setConfigDialogOpen = useConfigDialogStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigDialogStore((state) => state.clearPromptContinue);
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const finishConfig = () => {
setConfigDialogOpen(false);
if (!config.baseUrl.trim() || !config.imageModel.trim() || !config.textModel.trim() || !config.apiKey.trim()) return;
if (shouldPromptContinue) {
message.success("配置已保存,请继续刚才的请求");
} else {
message.success("配置已保存");
}
clearPromptContinue();
};
const refreshModels = async () => {
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
}
setLoadingModels(true);
try {
const models = await fetchImageModels(config);
onConfigChange("models", models);
if (models.length && !models.includes(config.imageModel)) onConfigChange("imageModel", models[0]);
if (models.length && !models.includes(config.textModel)) onConfigChange("textModel", models[0]);
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingModels(false);
}
};
return (
<>
{!hideHeader ? (
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
<div className="flex min-w-0 items-center">
<Link
href="/"
className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300"
>
<span
className="size-5 shrink-0 bg-current"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
/>
<span className="text-base font-medium"></span>
</Link>
<button
type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单"
title="导航菜单"
>
<Menu className="size-5" />
</button>
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
className={cn(
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
active
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
)}
>
<Icon className="size-4" />
<span className="truncate">{tool.label}</span>
</Link>
);
})}
</nav>
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
{isReady && user ? (
<UserStatusActions
version={appVersion}
theme={theme}
onThemeChange={setTheme}
onOpenConfig={() => openConfigDialog(false)}
userName={user.username}
menuItems={[
...(user.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
]}
/>
) : (
<>
<button
type="button"
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
onClick={() => openConfigDialog(false)}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
<AnimatedThemeToggler
theme={theme}
onThemeChange={setTheme}
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{appVersion}</span>
<GitHubLink />
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
</Link>
</>
)}
</div>
</div>
</header>
) : null}
<Drawer
title="导航"
placement="left"
size={280}
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
className="md:hidden"
>
<div className="space-y-1">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
onClick={() => setMobileNavOpen(false)}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
active
? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100"
: "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
)}
>
<Icon className="size-5" />
<span>{tool.label}</span>
</Link>
);
})}
</div>
</Drawer>
<Modal
title={<div><div className="text-lg font-semibold"></div><div className="mt-1 text-xs font-normal text-stone-500"></div></div>}
open={isConfigOpen}
width={560}
centered
onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" size="large" onClick={finishConfig}></Button>}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false} size="large">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => onConfigChange("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => onConfigChange("apiKey", event.target.value)} />
</Form.Item>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
<Button loading={loadingModels} onClick={() => void refreshModels()}></Button>
</div>
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={config} value={config.imageModel} onChange={(model) => onConfigChange("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-0">
<ModelPicker config={config} value={config.textModel} onChange={(model) => onConfigChange("textModel", model)} fullWidth />
</Form.Item>
</Form>
</div>
</Modal>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { GithubOutlined } from "@ant-design/icons";
import { cn } from "@/lib/utils";
type GitHubLinkProps = {
className?: string;
style?: React.CSSProperties;
};
export function GitHubLink({ className, style }: GitHubLinkProps) {
return (
<a
className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)}
style={style}
href="https://github.com/basketikun/infinite-canvas"
target="_blank"
rel="noreferrer"
aria-label="GitHub"
title="GitHub"
>
<GithubOutlined className="text-base" />
</a>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { LoaderCircle } from "lucide-react";
import { formatDuration } from "@/lib/image-utils";
import { cn } from "@/lib/utils";
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
const [tick, setTick] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, []);
const index = Math.floor(tick / 2) % pendingMessages.length;
const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88);
return (
<div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}>
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px",
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
}}
/>
<div className="absolute left-4 top-4 flex items-center gap-2 text-[15px] font-medium text-stone-500 dark:text-stone-300">
<LoaderCircle className="size-4 animate-spin" />
<span>{label || pendingMessages[index]}</span>
</div>
<div className="absolute bottom-4 left-4 right-4">
<div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400">
<span>{formatDuration(tick * 1000)}</span>
<span>{Math.floor(progress)}%</span>
</div>
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12">
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Select } from "antd";
import type { AiConfig } from "@/lib/ai-config";
type ModelPickerProps = {
config: AiConfig;
value?: string;
onChange: (model: string) => void;
className?: string;
fullWidth?: boolean;
placeholder?: string;
onMissingConfig?: () => void;
};
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: model }));
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
return (
<Select
showSearch
className={`canvas-control-select ${className || ""}`}
popupMatchSelectWidth={false}
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
value={value || undefined}
placeholder={placeholder}
options={options}
notFoundContent="请先到配置里拉取模型列表"
onChange={onChange}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
if (!options.length) onMissingConfig?.();
}}
filterOption={(input, option) => String(option?.label || "").toLowerCase().includes(input.toLowerCase())}
/>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Copy } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actionIcon = <Copy className="size-3.5" />, actionType = "text", extraAction }: { item: Prompt; onOpen: () => void; onCopy: () => void; actionLabel?: string; actionIcon?: ReactNode; actionType?: "text" | "primary"; extraAction?: ReactNode }) {
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={<button type="button" className="block w-full text-left" onClick={onOpen}><img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /></button>}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel}</Button>
{extraAction}
</div>
</Card>
);
}
@@ -0,0 +1,34 @@
"use client";
import { Copy, FolderPlus } from "lucide-react";
import { Button, Modal, Space, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return (
<>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
{prompt ? (
<>
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-3">
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
</div>
<div className="min-w-0">
<div className="flex flex-wrap gap-1.5">{prompt.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}</div>
<Space wrap className="mt-5">
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}></Button>
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}></Button> : null}
</Space>
</div>
</div>
</>
) : null}
</Modal>
</>
);
}
@@ -0,0 +1,80 @@
"use client";
import { Check, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
import { cn } from "@/lib/utils";
import { PromptCard } from "./prompt-card";
import { usePromptList } from "./use-prompt-list";
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const selectPrompt = (prompt: string) => {
onSelect(prompt);
onOpenChange(false);
};
useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}, [message, query.error, query.isError]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
};
return (
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-2xl">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" />
</div>
<div className="mt-5 grid gap-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategories.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
</Tag.CheckableTag>
);
})}
</div>
</div>
</div>
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
{query.isLoading ? <div className="flex h-40 items-center justify-center"><Spin /></div> : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
))}
</div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
{query.isFetchingNextPage ? <div className="py-4 text-center"><Spin size="small" /></div> : null}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,26 @@
"use client";
import { useMemo } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined,
enabled,
});
const firstPage = query.data?.pages[0];
return {
query,
items: useMemo(() => query.data?.pages.flatMap((page) => page.items) || [], [query.data?.pages]),
tags: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.tags || [])], [firstPage?.tags]),
categories: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.categories || [])], [firstPage?.categories]),
total: firstPage?.total || 0,
};
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
},
},
});
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useSyncThemeClass } from "@/stores/use-theme-store";
export function ThemeSync() {
useSyncThemeClass();
return null;
}
@@ -0,0 +1,269 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
export type TransitionVariant =
| "circle"
| "square"
| "triangle"
| "diamond"
| "hexagon"
| "rectangle"
| "star"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
variant?: TransitionVariant
/** When true, the transition expands from the viewport center instead of the button center. */
fromCenter?: boolean
theme?: "light" | "dark"
targetTheme?: "light" | "dark"
onThemeChange?: (theme: "light" | "dark") => void
}
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
const pairs = Array.from(
{ length: vertexCount },
() => `${cx}px ${cy}px`
).join(", ")
return `polygon(${pairs})`
}
function getThemeTransitionClipPaths(
variant: TransitionVariant,
cx: number,
cy: number,
maxRadius: number,
viewportWidth: number,
viewportHeight: number
): [string, string] {
switch (variant) {
case "circle":
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
case "square": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const halfSide = Math.max(halfW, halfH) * 1.05
const end = [
`${cx - halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy + halfSide}px`,
`${cx - halfSide}px ${cy + halfSide}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "triangle": {
const scale = maxRadius * 2.2
const dx = (Math.sqrt(3) / 2) * scale
const verts = [
`${cx}px ${cy - scale}px`,
`${cx + dx}px ${cy + 0.5 * scale}px`,
`${cx - dx}px ${cy + 0.5 * scale}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
}
case "diamond": {
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
const R = maxRadius * Math.SQRT2
const end = [
`${cx}px ${cy - R}px`,
`${cx + R}px ${cy}px`,
`${cx}px ${cy + R}px`,
`${cx - R}px ${cy}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "hexagon": {
const R = maxRadius * Math.SQRT2
const verts: string[] = []
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
}
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
}
case "rectangle": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const end = [
`${cx - halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy + halfH}px`,
`${cx - halfW}px ${cy + halfH}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "star": {
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
const R = maxRadius * Math.SQRT2 * 1.03
const innerRatio = 0.42
const starPolygon = (radius: number) => {
const verts: string[] = []
for (let i = 0; i < 5; i++) {
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
verts.push(
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
)
const innerA = outerA + Math.PI / 5
verts.push(
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
)
}
return `polygon(${verts.join(", ")})`
}
const startR = Math.max(2, R * 0.025)
return [starPolygon(startR), starPolygon(R)]
}
default:
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
}
}
export const AnimatedThemeToggler = ({
children,
className,
duration = 400,
variant,
fromCenter = false,
theme,
targetTheme,
onThemeChange,
...props
}: AnimatedThemeTogglerProps) => {
const shape = variant ?? "circle"
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (theme) {
setIsDark(theme === "dark")
return
}
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [theme])
const toggleTheme = useCallback(() => {
const button = buttonRef.current
if (!button) return
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
let x: number
let y: number
if (fromCenter) {
x = viewportWidth / 2
y = viewportHeight / 2
} else {
const { top, left, width, height } = button.getBoundingClientRect()
x = left + width / 2
y = top + height / 2
}
const maxRadius = Math.hypot(
Math.max(x, viewportWidth - x),
Math.max(y, viewportHeight - y)
)
const applyTheme = () => {
const nextTheme = targetTheme ?? (isDark ? "light" : "dark")
if (nextTheme === (isDark ? "dark" : "light")) return
setIsDark(nextTheme === "dark")
document.documentElement.classList.toggle("dark", nextTheme === "dark")
document.documentElement.style.colorScheme = nextTheme
onThemeChange?.(nextTheme)
}
if (typeof document.startViewTransition !== "function") {
applyTheme()
return
}
const clipPath = getThemeTransitionClipPaths(
shape,
x,
y,
maxRadius,
viewportWidth,
viewportHeight
)
const root = document.documentElement
root.dataset.magicuiThemeVt = "active"
root.style.setProperty(
"--magicui-theme-toggle-vt-duration",
`${duration}ms`
)
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
// theme unclipped between snapshot and the ready.then() JS animation.
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
const cleanup = () => {
delete root.dataset.magicuiThemeVt
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
root.style.removeProperty("--magicui-theme-vt-clip-from")
}
const transition = document.startViewTransition(() => {
flushSync(applyTheme)
})
if (typeof transition?.finished?.finally === "function") {
transition.finished.finally(cleanup)
} else {
cleanup()
}
const ready = transition?.ready
if (ready && typeof ready.then === "function") {
ready.then(() => {
document.documentElement.animate(
{
clipPath,
},
{
duration,
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
easing: shape === "star" ? "linear" : "ease-in-out",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
)
})
}
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
return (
<button
type="button"
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{children ?? (isDark ? <Sun /> : <Moon />)}
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
</button>
)
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
animate,
motion,
useInView,
useMotionValue,
useReducedMotion,
useTransform,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
const BAND_HALF = 17
const SWEEP_START = -BAND_HALF
const SWEEP_END = 100 + BAND_HALF
const sweepEase = (t: number) =>
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF
const bandEnd = pos + BAND_HALF
if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})`
}
const n = colors.length
const parts: string[] = []
if (bandStart > 0)
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
parts.push(`${c} ${pct.toFixed(2)}%`)
})
if (bandEnd < 100)
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
return `linear-gradient(90deg, ${parts.join(", ")})`
}
function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement
Object.assign(ghost.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
width: "auto",
whiteSpace: "nowrap",
})
el.parentElement!.appendChild(ghost)
const widths = texts.map((t) => {
ghost.textContent = t
return ghost.getBoundingClientRect().width
})
ghost.remove()
return widths
}
/**
* Props for {@link DiaTextReveal}.
*/
export interface DiaTextRevealProps extends Omit<
HTMLMotionProps<"span">,
"ref" | "children" | "style" | "animate" | "transition" | "color"
> {
/**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/
text: string | string[]
/**
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/
colors?: string[]
/**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"`
*/
textColor?: string
/**
* Duration of one sweep pass, in seconds.
* @defaultValue `1.5`
*/
duration?: number
/**
* Delay before the sweep starts, in seconds.
* @defaultValue `0`
*/
delay?: number
/**
* When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false`
*/
repeat?: boolean
/**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5`
*/
repeatDelay?: number
/**
* If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true`
*/
startOnView?: boolean
/**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true`
*/
once?: boolean
/**
* Additional class names for the animated `span` (e.g. typography utilities).
*/
className?: string
/**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false`
*/
fixedWidth?: boolean
}
export function DiaTextReveal({
text,
colors = DEFAULT_COLORS,
textColor = "var(--foreground)",
duration = 1.5,
delay = 0,
repeat = false,
repeatDelay = 0.5,
startOnView = true,
once = true,
className,
fixedWidth = false,
...props
}: DiaTextRevealProps) {
const texts = Array.isArray(text) ? text : [text]
const isMulti = texts.length > 1
const prefersReducedMotion = useReducedMotion()
const spanRef = useRef<HTMLSpanElement>(null)
const optsRef = useRef({
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
})
optsRef.current = {
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
}
const indexRef = useRef(0)
const hasPlayedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const playRef = useRef<() => void>(null!)
const stopRef = useRef<(() => void) | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
const sweepPos = useMotionValue(SWEEP_START)
const backgroundImage = useTransform(sweepPos, (pos) =>
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
)
const isInView = useInView(spanRef, { once, amount: 0.1 })
useEffect(() => {
const el = spanRef.current
if (!el || !isMulti) return
setMeasuredWidths(measureWidths(el, texts))
}, [Array.isArray(text) ? text.join("\0") : text])
playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
sweepPos.set(SWEEP_START)
const controls = animate(sweepPos, SWEEP_END, {
duration,
delay,
ease: sweepEase,
onComplete() {
if (!repeat) return
timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length
indexRef.current = next
setActiveIndex(next)
playRef.current()
}, repeatDelay * 1000)
},
})
stopRef.current = () => controls.stop()
}
useEffect(() => {
if (prefersReducedMotion) {
sweepPos.set(SWEEP_END)
return
}
if (startOnView && !isInView) return
if (once && hasPlayedRef.current) return
hasPlayedRef.current = true
playRef.current()
return () => {
stopRef.current?.()
clearTimeout(timerRef.current)
}
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
const fixedW =
isMulti && fixedWidth && measuredWidths.length > 0
? Math.max(...measuredWidths)
: undefined
const animatedW =
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
? measuredWidths[activeIndex]
: undefined
return (
<motion.span
ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{
transform: "translateY(-2px)",
color: "transparent",
backgroundClip: "text",
WebkitBackgroundClip: "text",
backgroundSize: "100% 100%",
backgroundImage,
...(isMulti && {
display: "inline-block",
overflow: "hidden",
whiteSpace: "nowrap",
verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }),
}),
}}
animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props}
>
{texts[activeIndex]}
</motion.span>
)
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { useUserStore } from "@/stores/use-user-store";
export function UserSessionSync() {
const hydrateUser = useUserStore((state) => state.hydrateUser);
useEffect(() => {
void hydrateUser();
}, [hydrateUser]);
return null;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import type { CSSProperties, ReactNode, RefObject } from "react";
import { Dropdown } from "antd";
import { Settings2 } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import { GitHubLink } from "@/components/github-link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import type { ThemeName } from "@/stores/use-theme-store";
type UserStatusActionsProps = {
version: string;
theme: ThemeName;
onThemeChange: (theme: ThemeName) => void;
onOpenConfig?: () => void;
showConfig?: boolean;
userName?: string;
initial?: string;
menuItems: ItemType[];
accountOpen?: boolean;
onAccountOpenChange?: (open: boolean) => void;
accountRef?: RefObject<HTMLDivElement | null>;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
avatarClassName?: string;
avatarStyle?: CSSProperties;
gitHubClassName?: string;
gitHubStyle?: CSSProperties;
userLabel?: ReactNode;
iconStyle?: CSSProperties;
};
export function UserStatusActions({
version,
theme,
onThemeChange,
onOpenConfig,
showConfig = true,
userName,
initial,
menuItems,
accountOpen,
onAccountOpenChange,
accountRef,
getPopupContainer,
avatarClassName,
avatarStyle,
gitHubClassName,
gitHubStyle,
userLabel,
iconStyle,
}: UserStatusActionsProps) {
const avatarText = initial || (userName?.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
return (
<div className="inline-flex shrink-0 items-center gap-1.5">
{showConfig ? (
<button
type="button"
className={naturalIconClass}
style={iconStyle}
onClick={onOpenConfig}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler
theme={theme}
onThemeChange={onThemeChange}
className={naturalIconClass}
style={iconStyle}
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{version}</span>
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown
open={accountOpen}
onOpenChange={onAccountOpenChange}
trigger={["click"]}
placement="bottomRight"
getPopupContainer={getPopupContainer}
styles={{ root: { minWidth: 150 } }}
menu={{ items: menuItems }}
>
<button
type="button"
className={cn("inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white", avatarClassName)}
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{userLabel ?? avatarText}</span>
</button>
</Dropdown>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
export type AiConfig = {
baseUrl: string;
apiKey: string;
model: string;
imageModel: string;
textModel: string;
models: string[];
quality: string;
size: string;
count: string;
};
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
export const defaultConfig: AiConfig = {
baseUrl: "https://api.openai.com",
apiKey: "",
model: "gpt-image-2",
imageModel: "gpt-image-2",
textModel: "gpt-5.5",
models: [],
quality: "auto",
size: "1:1",
count: "1",
};
export function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, "");
}
export function buildApiUrl(baseUrl: string, path: string) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
+63
View File
@@ -0,0 +1,63 @@
export type CanvasColorTheme = "light" | "dark";
export type CanvasBackgroundMode = "dots" | "lines" | "blank";
export const canvasThemes = {
light: {
canvas: {
background: "#f4f2ed",
dot: "rgba(68,64,60,.28)",
line: "rgba(68,64,60,.12)",
selectionStroke: "#1c1917",
selectionFill: "rgba(28,25,23,.06)",
},
node: {
label: "#57534e",
fill: "#e7e5df",
panel: "#fbfaf7",
stroke: "#d6d3ca",
activeStroke: "#1c1917",
placeholder: "#8a8479",
text: "#292524",
muted: "#78716c",
faint: "#a8a29e",
},
toolbar: {
panel: "rgba(251,250,247,.96)",
border: "#d6d3ca",
item: "#57534e",
itemHover: "#e7e5df",
activeBg: "#e7e5df",
activeText: "#292524",
},
},
dark: {
canvas: {
background: "#181715",
dot: "rgba(245,245,244,.24)",
line: "rgba(245,245,244,.10)",
selectionStroke: "#fafaf9",
selectionFill: "rgba(250,250,249,.10)",
},
node: {
label: "#d6d3d1",
fill: "#292524",
panel: "#1f1d1a",
stroke: "#44403c",
activeStroke: "#fafaf9",
placeholder: "#a8a29e",
text: "#f5f5f4",
muted: "#d6d3d1",
faint: "#78716c",
},
toolbar: {
panel: "rgba(31,29,26,.96)",
border: "#44403c",
item: "#d6d3d1",
itemHover: "#292524",
activeBg: "#3a3631",
activeText: "#f5f5f4",
},
},
} as const;
export type CanvasTheme = (typeof canvasThemes)[CanvasColorTheme];
+3
View File
@@ -0,0 +1,3 @@
export function createId() {
return crypto.randomUUID();
}
+62
View File
@@ -0,0 +1,62 @@
import type { ReferenceImage } from "@/types/image";
export function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
export function formatDuration(ms: number) {
const value = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(value / 60);
const seconds = value % 60;
return minutes ? `${minutes}${String(seconds).padStart(2, "0")}` : `${seconds}`;
}
export function getDataUrlByteSize(dataUrl: string) {
const base64 = dataUrl.split(",", 2)[1];
if (!base64) {
return 0;
}
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
}
export function readImageMeta(dataUrl: string) {
return new Promise<{ width: number; height: number; mimeType: string }>((resolve) => {
const image = new Image();
const done = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
image.onload = done;
image.onerror = done;
setTimeout(done, 3000);
image.src = dataUrl;
});
}
export function dataUrlToFile(image: ReferenceImage) {
const [header, content] = image.dataUrl.split(",", 2);
const mimeType = header.match(/data:(.*?);base64/)?.[1] || image.type || "image/png";
const binary = atob(content || "");
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], image.name || "reference.png", { type: mimeType });
}
+34
View File
@@ -0,0 +1,34 @@
import localforage from "localforage";
import type { StateStorage } from "zustand/middleware";
localforage.config({
name: "infinite-canvas",
storeName: "app_state",
});
export const localForageStorage: StateStorage = {
getItem: async (name) => {
if (typeof window === "undefined") return null;
try {
return (await localforage.getItem<string>(name)) || null;
} catch {
return window.localStorage.getItem(name);
}
},
setItem: async (name, value) => {
if (typeof window === "undefined") return;
try {
await localforage.setItem(name, value);
} catch {
window.localStorage.setItem(name, value);
}
},
removeItem: async (name) => {
if (typeof window === "undefined") return;
try {
await localforage.removeItem(name);
} catch {
window.localStorage.removeItem(name);
}
},
};
+26
View File
@@ -0,0 +1,26 @@
import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react";
export const navigationTools = [
{
slug: "canvas",
label: "我的画布",
icon: Maximize2,
},
{
slug: "image",
label: "生图工作台",
icon: ImagePlus,
},
{
slug: "prompts",
label: "提示词库",
icon: FileText,
},
{
slug: "assets",
label: "我的素材",
icon: Images,
},
] as const;
export type NavigationToolSlug = (typeof navigationTools)[number]["slug"];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+83
View File
@@ -0,0 +1,83 @@
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
export type AdminPromptCategory = {
category: string;
name: string;
description: string;
file: string;
githubUrl: string;
remote: boolean;
};
export async function fetchAdminPromptCategories(token: string) {
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
}
export async function syncAdminPromptCategory(token: string, category: string) {
return apiPost<AdminPromptCategory[]>("/api/admin/prompt-categories/sync", { category }, token);
}
export type AdminPromptQuery = {
keyword?: string;
category?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export type AdminAsset = {
id: string;
title: string;
type: "text" | "image" | "video";
coverUrl: string;
tags: string[];
category: string;
description: string;
content: string;
url: string;
createdAt: string;
updatedAt: string;
};
export type AdminAssetListResponse = {
items: AdminAsset[];
tags: string[];
total: number;
};
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
return apiGet<PromptListResponse>("/api/admin/prompts", compactApiParams(query), token);
}
export async function saveAdminPrompt(token: string, prompt: Partial<Prompt>) {
return apiPost<Prompt>("/api/admin/prompts", prompt, token);
}
export async function deleteAdminPrompt(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
}
export type AdminAssetQuery = {
keyword?: string;
type?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
return apiGet<AdminAssetListResponse>(
"/api/admin/assets",
compactApiParams(query),
token,
);
}
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
return apiPost<AdminAsset>("/api/admin/assets", asset, token);
}
export async function deleteAdminAsset(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
}
+33
View File
@@ -0,0 +1,33 @@
import { apiGet, compactApiParams } from "@/services/api/request";
export type AssetLibraryItem = {
id: string;
title: string;
type: "text" | "image" | "video";
coverUrl: string;
tags: string[];
category: string;
description: string;
content: string;
url: string;
createdAt: string;
updatedAt: string;
};
export type AssetLibraryResponse = {
items: AssetLibraryItem[];
tags: string[];
total: number;
};
export type AssetLibraryQuery = {
keyword?: string;
type?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
}
+35
View File
@@ -0,0 +1,35 @@
import { apiGet, apiPost } from "@/services/api/request";
export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1";
export type UserRole = "guest" | "user" | "admin";
export type AuthUser = {
id: string;
username: string;
role: UserRole;
createdAt: string;
updatedAt: string;
};
export type AuthSession = {
token: string;
user: AuthUser;
};
export type AuthPayload = {
username: string;
password: string;
};
export async function login(payload: AuthPayload) {
return apiPost<AuthSession>("/api/admin/login", payload);
}
export async function register(payload: AuthPayload) {
return apiPost<AuthSession>("/api/auth/register", payload);
}
export async function fetchCurrentUser(token?: string) {
return apiGet<AuthUser>("/api/auth/me", undefined, token);
}
+176
View File
@@ -0,0 +1,176 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/lib/ai-config";
import { createId } from "@/lib/id";
import { dataUrlToFile } from "@/lib/image-utils";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
export type ChatCompletionMessage = {
role: "user" | "assistant";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
};
type ImageApiResponse = {
data?: Array<Record<string, unknown>>;
error?: { message?: string };
};
function resolveImageDataUrl(item: Record<string, unknown>) {
if (typeof item.b64_json === "string" && item.b64_json) {
return `data:image/png;base64,${item.b64_json}`;
}
if (typeof item.url === "string" && item.url) {
return item.url;
}
return null;
}
function parseImagePayload(payload: ImageApiResponse) {
const images =
payload.data
?.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: createId(), dataUrl })) || [];
if (images.length === 0) {
throw new Error("接口没有返回图片");
}
return images;
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isAxiosError<{ error?: { message?: string } }>(error)) {
return error.response?.data?.error?.message || (error.response?.status ? `${fallback}${error.response.status}` : fallback);
}
return error instanceof Error ? error.message : fallback;
}
function parseStreamChunk(chunk: string, onDelta: (value: string) => void) {
let deltaText = "";
for (const eventBlock of chunk.split("\n\n")) {
const data = eventBlock.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (!data || data === "[DONE]") continue;
const delta = (JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content || "";
deltaText += delta;
}
if (deltaText) onDelta(deltaText);
}
export async function requestGeneration(config: AiConfig, prompt: string) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
try {
const response = await axios.post<ImageApiResponse>(
buildApiUrl(config.baseUrl, "/images/generations"),
{
model: config.model,
prompt,
n,
quality: config.quality || undefined,
size: config.size || undefined,
response_format: "b64_json",
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
},
);
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const formData = new FormData();
formData.set("model", config.model);
formData.set("prompt", prompt);
formData.set("n", String(n));
formData.set("response_format", "b64_json");
if (config.quality) {
formData.set("quality", config.quality);
}
if (config.size) {
formData.set("size", config.size);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
try {
const response = await axios.post<ImageApiResponse>(buildApiUrl(config.baseUrl, "/images/edits"), formData, {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestImageQuestion(config: AiConfig, messages: ChatCompletionMessage[], onDelta: (text: string) => void) {
let buffer = "";
let answer = "";
let processedLength = 0;
try {
await axios.post(
buildApiUrl(config.baseUrl, "/chat/completions"),
{
model: config.model,
messages,
stream: true,
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
responseType: "text",
onDownloadProgress: (event) => {
const responseText = String(event.event?.target?.responseText || "");
const nextText = responseText.slice(processedLength);
processedLength = responseText.length;
buffer += nextText;
const chunks = buffer.split("\n\n");
buffer = chunks.pop() || "";
for (const chunk of chunks) {
parseStreamChunk(chunk, (delta) => {
answer += delta;
onDelta(answer);
});
}
},
},
);
if (buffer) {
parseStreamChunk(buffer, (delta) => {
answer += delta;
onDelta(answer);
});
}
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
return (response.data.data || [])
.map((model) => model.id)
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
} catch (error) {
throw new Error(readAxiosError(error, "读取模型失败"));
}
}
+38
View File
@@ -0,0 +1,38 @@
import { apiGet, compactApiParams } from "@/services/api/request";
export type Prompt = {
id: string;
title: string;
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
export const ALL_PROMPTS_OPTION = "全部";
export type PromptListResponse = {
items: Prompt[];
tags: string[];
categories: string[];
total: number;
};
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
return apiGet<PromptListResponse>("/api/prompts", compactApiParams({
...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}),
...(category !== ALL_PROMPTS_OPTION ? { category } : {}),
...(page ? { page } : {}),
...(pageSize ? { pageSize } : {}),
}));
}
export function formatPromptDate(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
}
+83
View File
@@ -0,0 +1,83 @@
import axios from "axios";
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
type ApiResponse<T> = {
code: number;
data: T;
msg: string;
};
export function compactApiParams(params: ApiParams) {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0)),
) as ApiParams;
}
export function serializeApiParams(params?: ApiParams) {
const queryParams = new URLSearchParams();
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined) continue;
if (Array.isArray(value)) value.forEach((item) => queryParams.append(key, String(item)));
else queryParams.set(key, String(value));
}
return queryParams;
}
export async function apiGet<T>(url: string, params?: ApiParams, token?: string) {
return apiRequest<T>({
url,
method: "GET",
params: params || undefined,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
export async function apiPost<T>(url: string, body?: unknown, token?: string) {
return apiRequest<T>({
url,
method: "POST",
data: body ?? {},
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
export async function apiDelete<T>(url: string, token?: string) {
return apiRequest<T>({
url,
method: "DELETE",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
async function apiRequest<T>(config: { url: string; method: "GET" | "POST" | "DELETE"; params?: ApiParams; data?: unknown; headers?: Record<string, string> }) {
let response;
try {
response = await axios.request<ApiResponse<T>>({
url: config.url,
method: config.method,
params: config.params,
paramsSerializer: { serialize: (params) => serializeApiParams(params as ApiParams).toString() },
data: config.data,
headers: config.headers,
validateStatus: () => true,
});
} catch {
throw new Error("接口连接失败,请确认后端服务已启动");
}
const result = response.data;
if (!result || typeof result !== "object") {
throw new Error(response.status === 404 ? "接口不存在,请确认后端服务已启动" : "接口返回异常,请稍后重试");
}
const payload = result as ApiResponse<T>;
if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
return payload.data;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import localforage from "localforage";
import { createId } from "@/lib/id";
import { readImageMeta } from "@/lib/image-utils";
export type UploadedImage = {
url: string;
storageKey: string;
width: number;
height: number;
bytes: number;
mimeType: string;
};
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "image_files" });
const objectUrls = new Map<string, string>();
export async function uploadImage(input: string | Blob): Promise<UploadedImage> {
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
const storageKey = `image:${createId()}`;
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
const meta = await readImageMeta(url);
return { url, storageKey, width: meta.width, height: meta.height, bytes: blob.size, mimeType: blob.type || meta.mimeType };
}
export async function resolveImageUrl(storageKey?: string, fallback = "") {
if (!storageKey) return fallback;
const cached = objectUrls.get(storageKey);
if (cached) return cached;
const blob = await store.getItem<Blob>(storageKey);
if (!blob) return fallback;
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) {
const url = image.dataUrl || await resolveImageUrl(image.storageKey, image.url || "");
if (!url || url.startsWith("data:")) return url;
return blobToDataUrl(await (await fetch(url)).blob());
}
export async function deleteStoredImages(keys: Iterable<string>) {
await Promise.all(Array.from(new Set(keys)).map(async (key) => {
const url = objectUrls.get(key);
if (url) URL.revokeObjectURL(url);
objectUrls.delete(key);
await store.removeItem(key);
}));
}
export async function cleanupUnusedImages(usedData: unknown) {
const usedKeys = collectImageStorageKeys(usedData);
const unused: string[] = [];
await store.iterate((_value, key) => {
if (!usedKeys.has(key)) unused.push(key);
});
await deleteStoredImages(unused);
}
export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return keys;
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.startsWith("image:")) keys.add(value.storageKey);
Object.values(value).forEach((item) => Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys));
return keys;
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
+31
View File
@@ -0,0 +1,31 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { CONFIG_STORE_KEY, defaultConfig, type AiConfig } from "@/lib/ai-config";
type AiConfigStore = {
config: AiConfig;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
};
export const useAiConfigStore = create<AiConfigStore>()(
persist(
(set) => ({
config: defaultConfig,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
}),
{
name: CONFIG_STORE_KEY,
merge: (persisted, current) => {
const config = { ...defaultConfig, ...((persisted as Partial<AiConfigStore>).config || {}) };
return { ...current, config: { ...config, imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } };
},
},
),
);
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { createId } from "@/lib/id";
import { localForageStorage } from "@/lib/localforage-storage";
import { cleanupUnusedImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
export type AssetKind = "text" | "image";
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
export type Asset = TextAsset | ImageAsset;
type AssetBase<T extends AssetKind> = {
id: string;
kind: T;
title: string;
coverUrl: string;
tags: string[];
source?: string;
note?: string;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
};
type AssetStore = {
assets: Asset[];
addAsset: (asset: Omit<Asset, "id" | "createdAt" | "updatedAt">) => string;
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
removeAsset: (id: string) => void;
cleanupImages: (extra?: unknown) => void;
};
const ASSET_STORE_KEY = "infinite-canvas:asset_store";
const assetStorage: PersistStorage<AssetStore> = {
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<AssetStore>;
parsed.state.assets = await Promise.all(parsed.state.assets.map(async (asset) => {
if (asset.kind !== "image") return asset;
if (asset.data.storageKey) return {
...asset,
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl,
data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
};
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
const image = await uploadImage(asset.data.dataUrl);
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
}));
return parsed;
},
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
removeItem: (name) => localForageStorage.removeItem(name),
};
export const useAssetStore = create<AssetStore>()(
persist(
(set, get) => ({
assets: [],
addAsset: (asset) => {
const now = new Date().toISOString();
const id = createId();
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] }));
return id;
},
updateAsset: (id, patch) => set((state) => ({
assets: state.assets.map((asset) => asset.id === id ? { ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset : asset),
})),
removeAsset: (id) => set((state) => {
const assets = state.assets.filter((asset) => asset.id !== id);
get().cleanupImages({ assets });
return { assets };
}),
cleanupImages: (extra) => {
window.setTimeout(async () => {
const { useCanvasStore } = await import("@/app/(user)/canvas/stores/use-canvas-store");
await cleanupUnusedImages({ assets: get().assets, projects: useCanvasStore.getState().projects, extra });
}, 0);
},
}),
{
name: ASSET_STORE_KEY,
storage: assetStorage,
partialize: (state) => ({ assets: state.assets }) as StorageValue<AssetStore>["state"],
},
),
);
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
type ConfigDialogStore = {
isOpen: boolean;
shouldPromptContinue: boolean;
openConfigDialog: (shouldPromptContinue?: boolean) => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
export const useConfigDialogStore = create<ConfigDialogStore>()((set) => ({
isOpen: false,
shouldPromptContinue: false,
openConfigDialog: (shouldPromptContinue = false) =>
set({
isOpen: true,
shouldPromptContinue,
}),
setConfigDialogOpen: (isOpen) => set({ isOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}));
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ThemeName = "light" | "dark";
type ThemeStore = {
theme: ThemeName;
setTheme: (theme: ThemeName) => void;
};
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
}),
{ name: "infinite-canvas:theme_store" },
),
);
export function useSyncThemeClass() {
const theme = useThemeStore((state) => state.theme);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}, [theme]);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { AUTH_TOKEN_KEY, fetchCurrentUser, login, register, type AuthPayload, type AuthUser } from "@/services/api/auth";
type UserStore = {
token: string;
user: AuthUser | null;
isReady: boolean;
isLoading: boolean;
setSession: (token: string, user: AuthUser) => void;
clearSession: () => void;
hydrateUser: () => Promise<void>;
login: (payload: AuthPayload) => Promise<AuthUser>;
register: (payload: AuthPayload) => Promise<AuthUser>;
};
export const useUserStore = create<UserStore>()(
persist(
(set, get) => ({
token: "",
user: null,
isReady: false,
isLoading: false,
setSession: (token, user) => set({ token, user, isReady: true }),
clearSession: () => set({ token: "", user: null, isReady: true }),
hydrateUser: async () => {
const token = get().token;
if (!token) {
set({ user: null, isReady: true });
return;
}
set({ isLoading: true });
try {
const user = await fetchCurrentUser(token);
if (user.role === "guest") {
set({ token: "", user: null, isReady: true, isLoading: false });
return;
}
set({ user, isReady: true, isLoading: false });
} catch {
set({ token: "", user: null, isReady: true, isLoading: false });
}
},
login: async (payload) => {
set({ isLoading: true });
try {
const session = await login(payload);
set({ token: session.token, user: session.user, isReady: true, isLoading: false });
return session.user;
} catch (error) {
set({ isLoading: false });
throw error;
}
},
register: async (payload) => {
set({ isLoading: true });
try {
const session = await register(payload);
set({ token: session.token, user: session.user, isReady: true, isLoading: false });
return session.user;
} catch (error) {
set({ isLoading: false });
throw error;
}
},
}),
{
name: AUTH_TOKEN_KEY,
partialize: (state) => ({ token: state.token }),
onRehydrateStorage: () => (state) => {
if (state) state.isReady = false;
},
},
),
);
+8
View File
@@ -0,0 +1,8 @@
export type ReferenceImage = {
id: string;
name: string;
type: string;
dataUrl: string;
url?: string;
storageKey?: string;
};
+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"next.config.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}