style(code): 格式化代码缩进和布局样式
- 统一调整 admin.ts 文件中的接口定义缩进格式 - 优化 animated-theme-toggler.tsx 组件的代码结构和缩进 - 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式 - 整理 app-providers.tsx 中的组件层级缩进 - 标准化 app-theme.ts 中的对象属性缩进格式
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 255
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
bun.lock
|
||||
*.log
|
||||
*.ico
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.webp
|
||||
*.svg
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 255,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
@@ -1164,6 +1165,8 @@
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
+25
-25
@@ -1,27 +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}"
|
||||
}
|
||||
"$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}"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -10,17 +10,17 @@ const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim()
|
||||
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
|
||||
|
||||
export default function nextConfig(phase: string): NextConfig {
|
||||
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||
const releases = parseChangelog(localChangelog);
|
||||
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||
const releases = parseChangelog(localChangelog);
|
||||
|
||||
return {
|
||||
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: localVersion,
|
||||
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
|
||||
},
|
||||
};
|
||||
return {
|
||||
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: localVersion,
|
||||
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+45
-42
@@ -1,44 +1,47 @@
|
||||
{
|
||||
"name": "infinite-canvas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/nextjs-registry": "^1.3.0",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"antd": "^6.4.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"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"
|
||||
}
|
||||
"name": "infinite-canvas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/nextjs-registry": "^1.3.0",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"antd": "^6.4.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"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",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -12,147 +12,250 @@ import { useAdminAssets } from "./use-admin-assets";
|
||||
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ 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 copyText = useCopyText();
|
||||
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 }));
|
||||
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
|
||||
const copyText = useCopyText();
|
||||
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]);
|
||||
useEffect(() => {
|
||||
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
|
||||
}, [editingAsset, form]);
|
||||
|
||||
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 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
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>
|
||||
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>
|
||||
{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={() => copyText(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>
|
||||
);
|
||||
<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={() => copyText(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,83 +10,83 @@ 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 { 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 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 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 : "删除失败");
|
||||
},
|
||||
});
|
||||
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]);
|
||||
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 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 data = query.data;
|
||||
const data = query.data;
|
||||
|
||||
return {
|
||||
assets: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
kind: type,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
||||
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: () => query.refetch(),
|
||||
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
|
||||
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
return {
|
||||
assets: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
kind: type,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
||||
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: () => query.refetch(),
|
||||
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
|
||||
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,68 +12,86 @@ import { adminLayoutStyle } from "@/lib/app-theme";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const adminMenus = [
|
||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const { token: antToken } = theme.useToken();
|
||||
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 activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
|
||||
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
|
||||
const { token: antToken } = theme.useToken();
|
||||
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 activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
|
||||
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (!token) {
|
||||
router.replace("/login?redirect=/admin");
|
||||
return;
|
||||
}
|
||||
if (user?.role !== "admin") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [isReady, router, token, user?.role]);
|
||||
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: antToken.colorBgLayout }}>
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isReady || !token || user?.role !== "admin") {
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}>
|
||||
<span />
|
||||
</div>
|
||||
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
|
||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
||||
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
|
||||
无限画布
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
style={adminLayoutStyle.menu}
|
||||
items={adminMenus.map((item) => ({
|
||||
...item,
|
||||
label: (
|
||||
<Link href={item.key} style={{ color: "inherit" }}>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
style: adminLayoutStyle.menuItem,
|
||||
}))}
|
||||
/>
|
||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
|
||||
前往画布
|
||||
</Button>
|
||||
<Button block icon={<LogoutOutlined />} onClick={logout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Flex>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ background: antToken.colorBgLayout }}>
|
||||
<Layout.Header
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{pageTitle}
|
||||
</Typography.Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<UserStatusActions showConfig={false} />
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
|
||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
||||
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>无限画布</Typography.Text>
|
||||
</Flex>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
style={adminLayoutStyle.menu}
|
||||
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))}
|
||||
/>
|
||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">前往画布</Button>
|
||||
<Button block icon={<LogoutOutlined />} onClick={logout}>退出登录</Button>
|
||||
</Flex>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ background: antToken.colorBgLayout }}>
|
||||
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<UserStatusActions showConfig={false} />
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/assets");
|
||||
redirect("/admin/assets");
|
||||
}
|
||||
|
||||
@@ -10,159 +10,328 @@ import type { Prompt } from "@/services/api/prompts";
|
||||
import { useAdminPrompts } from "./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, deletePrompts } = useAdminPrompts();
|
||||
const copyText = useCopyText();
|
||||
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 [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
|
||||
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
|
||||
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 }));
|
||||
const {
|
||||
categories,
|
||||
prompts,
|
||||
tags,
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
isLoading,
|
||||
isSyncing,
|
||||
searchPrompts,
|
||||
changeCategory,
|
||||
changeTag,
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetFilters,
|
||||
refreshPrompts,
|
||||
syncCategory,
|
||||
savePrompt: saveAdminPrompt,
|
||||
deletePrompt,
|
||||
deletePrompts,
|
||||
} = useAdminPrompts();
|
||||
const copyText = useCopyText();
|
||||
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 [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
|
||||
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
|
||||
}, [editingPrompt, form]);
|
||||
|
||||
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 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 batchDeletePrompts = async () => {
|
||||
await deletePrompts(selectedPromptIds);
|
||||
setSelectedPromptIds([]);
|
||||
setIsBatchDeleteOpen(false);
|
||||
};
|
||||
const batchDeletePrompts = async () => {
|
||||
await deletePrompts(selectedPromptIds);
|
||||
setSelectedPromptIds([]);
|
||||
setIsBatchDeleteOpen(false);
|
||||
};
|
||||
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
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() }}
|
||||
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}</Button>,
|
||||
<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>
|
||||
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() }}
|
||||
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
|
||||
批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
|
||||
</Button>,
|
||||
<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>
|
||||
{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={() => copyText(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)} mask={{ closable: !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={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(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||
</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={() => copyText(detailPrompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{detailPrompt.githubUrl ? (
|
||||
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
|
||||
远程源
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
<Modal
|
||||
title="同步远程提示词源"
|
||||
open={isSyncOpen}
|
||||
width={640}
|
||||
onCancel={() => !isSyncing && setIsSyncOpen(false)}
|
||||
mask={{ closable: !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>
|
||||
|
||||
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,123 +11,123 @@ 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 { 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 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 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 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 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 : "删除失败");
|
||||
},
|
||||
});
|
||||
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 : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||
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 : "批量删除失败");
|
||||
},
|
||||
});
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||
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(() => {
|
||||
const error = categoriesQuery.error || promptsQuery.error;
|
||||
if (!error) return;
|
||||
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
|
||||
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
|
||||
useEffect(() => {
|
||||
const error = categoriesQuery.error || promptsQuery.error;
|
||||
if (!error) return;
|
||||
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
|
||||
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
|
||||
|
||||
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 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 data = promptsQuery.data;
|
||||
const data = promptsQuery.data;
|
||||
|
||||
return {
|
||||
categories: categoriesQuery.data || [],
|
||||
prompts: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||
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: async () => {
|
||||
await categoriesQuery.refetch();
|
||||
await promptsQuery.refetch();
|
||||
},
|
||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||
};
|
||||
return {
|
||||
categories: categoriesQuery.data || [],
|
||||
prompts: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||
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: async () => {
|
||||
await categoriesQuery.refetch();
|
||||
await promptsQuery.refetch();
|
||||
},
|
||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,200 +15,274 @@ import { uploadImage } from "@/services/image-storage";
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function AssetLibraryPage() {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
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 { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
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,
|
||||
});
|
||||
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 : "获取素材库失败");
|
||||
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("加入失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [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("加入失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
<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={() => copyText(selectedAsset.content)}>
|
||||
复制文本
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedAsset.type === "image" ? (
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
|
||||
复制链接
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</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={() => copyText(selectedAsset.content)}>复制文本</Button> : null}
|
||||
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => 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>
|
||||
);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
+436
-326
@@ -11,373 +11,483 @@ 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;
|
||||
kind: AssetKind;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
source?: string;
|
||||
note?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type ImageDraft = ImageAsset["data"] | null;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
export default function AssetsPage() {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const 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 { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
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 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]);
|
||||
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]);
|
||||
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" },
|
||||
const openCreate = () => {
|
||||
setEditingAsset(null);
|
||||
setImageDraft(null);
|
||||
setFormKind("text");
|
||||
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
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" },
|
||||
};
|
||||
|
||||
const readCoverFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
form.setFieldValue("coverUrl", dataUrl);
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
const copyAssetText = async (asset: Asset) => {
|
||||
if (asset.kind !== "text") return;
|
||||
copyText(asset.data.content, "文本已复制");
|
||||
};
|
||||
const readCoverFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
form.setFieldValue("coverUrl", dataUrl);
|
||||
};
|
||||
|
||||
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 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 confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
const copyAssetText = async (asset: Asset) => {
|
||||
if (asset.kind !== "text") return;
|
||||
copyText(asset.data.content, "文本已复制");
|
||||
};
|
||||
|
||||
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>
|
||||
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();
|
||||
};
|
||||
|
||||
<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>
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
<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>
|
||||
))}
|
||||
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>
|
||||
<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={copyAssetText}
|
||||
onDownload={downloadImage}
|
||||
onDelete={() => setDeletingAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
{!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 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>
|
||||
</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>}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void readCoverFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void readImageFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
void readCoverFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
void readImageFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
</Modal>
|
||||
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||
|
||||
<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>
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
</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>
|
||||
);
|
||||
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}`;
|
||||
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();
|
||||
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
@@ -1,5 +1,5 @@
|
||||
import CanvasClientPage from "./canvas-client-page";
|
||||
|
||||
export default function CanvasPage() {
|
||||
return <CanvasClientPage />;
|
||||
return <CanvasClientPage />;
|
||||
}
|
||||
|
||||
@@ -12,254 +12,251 @@ 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 };
|
||||
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;
|
||||
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);
|
||||
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab);
|
||||
}, [open, 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>
|
||||
);
|
||||
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" },
|
||||
{ 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 { 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 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 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);
|
||||
}
|
||||
};
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
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);
|
||||
});
|
||||
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 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 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]);
|
||||
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]);
|
||||
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 });
|
||||
}
|
||||
};
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,241 +14,292 @@ 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;
|
||||
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 = useConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigStore((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 { message } = App.useApp();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const globalConfig = useConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigStore((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("已保存文本提示词");
|
||||
};
|
||||
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 cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-sm font-semibold">生成配置</div>
|
||||
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<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>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 cursor-pointer 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 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<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 !cursor-pointer !rounded-lg"
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
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} />)}
|
||||
return (
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-sm font-semibold">生成配置</div>
|
||||
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<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>
|
||||
</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 className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer 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 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<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 !cursor-pointer !rounded-lg"
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
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>
|
||||
<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 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>
|
||||
</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>
|
||||
);
|
||||
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 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 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)} />
|
||||
</>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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),
|
||||
};
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,72 +2,52 @@ 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}`;
|
||||
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>
|
||||
);
|
||||
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;
|
||||
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}`;
|
||||
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" />;
|
||||
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
|
||||
}
|
||||
|
||||
@@ -8,63 +8,38 @@ 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)];
|
||||
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]);
|
||||
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>
|
||||
);
|
||||
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)];
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,27 +7,34 @@ 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([]);
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,167 +8,217 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const qualityOptions = [{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }];
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
];
|
||||
const aspectOptions = [
|
||||
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
|
||||
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
|
||||
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
|
||||
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
|
||||
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
|
||||
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
|
||||
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
|
||||
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
|
||||
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
|
||||
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
|
||||
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
|
||||
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
|
||||
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
|
||||
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
|
||||
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
|
||||
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
|
||||
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
|
||||
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
|
||||
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
|
||||
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
|
||||
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
|
||||
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
|
||||
];
|
||||
|
||||
type CanvasImageSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
onMissingConfig?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
onMissingConfig?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
};
|
||||
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const quality = config.quality || "auto";
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const activeSize = config.size || "auto";
|
||||
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const selectAspect = (value: string) => {
|
||||
const option = aspectOptions.find((item) => item.value === value);
|
||||
onConfigChange("size", option?.size || option?.value || "auto");
|
||||
};
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const quality = config.quality || "auto";
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const activeSize = config.size || "auto";
|
||||
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const selectAspect = (value: string) => {
|
||||
const option = aspectOptions.find((item) => item.value === value);
|
||||
onConfigChange("size", option?.size || option?.value || "auto");
|
||||
};
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement={placement}
|
||||
arrow={false}
|
||||
overlayClassName="canvas-image-settings-popover"
|
||||
color={theme.toolbar.panel}
|
||||
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
|
||||
onOpenChange={onOpenChange}
|
||||
content={(
|
||||
<CanvasImageSettingsTheme theme={theme}>
|
||||
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-xl font-semibold">图像设置</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{qualityOptions.map((item) => <OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>{item.label}</OptionPill>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{aspectOptions.map((item) => (
|
||||
<button key={item.value} type="button" className="flex h-[86px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-sm transition hover:opacity-80" style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={() => selectAspect(item.value)}>
|
||||
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => <OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>{value} 张</OptionPill>)}
|
||||
<CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasImageSettingsTheme>
|
||||
)}
|
||||
>
|
||||
<Button size="small" type="text" className={buttonClassName || "!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)} · {selectedAspect?.label || activeSize} · {count} 张</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement={placement}
|
||||
arrow={false}
|
||||
overlayClassName="canvas-image-settings-popover"
|
||||
color={theme.toolbar.panel}
|
||||
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
|
||||
onOpenChange={onOpenChange}
|
||||
content={
|
||||
<CanvasImageSettingsTheme theme={theme}>
|
||||
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-xl font-semibold">图像设置</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{qualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{aspectOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[86px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-sm transition hover:opacity-80"
|
||||
style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => selectAspect(item.value)}
|
||||
>
|
||||
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
|
||||
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||
{value} 张
|
||||
</OptionPill>
|
||||
))}
|
||||
<CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasImageSettingsTheme>
|
||||
}
|
||||
>
|
||||
<Button size="small" type="text" className={buttonClassName || "!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)} · {selectedAspect?.label || activeSize} · {count} 张
|
||||
</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasImageSettingsTheme({ 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 } } }}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
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 } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>{prefix}</span>
|
||||
<input type="number" min={1} disabled={disabled} className="min-w-0 flex-1 bg-transparent px-2 outline-none" value={value || ""} onChange={(event) => onChange(Number(event.target.value) || null)} onMouseDown={(event) => event.stopPropagation()} />
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 outline-none"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="col-span-2 flex h-10 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input type="number" min={1} max={15} className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none" style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }} value={value || ""} onChange={(event) => onChange(Number(event.target.value) || null)} onMouseDown={(event) => event.stopPropagation()} />
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<label className="col-span-2 flex h-10 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none"
|
||||
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) {
|
||||
if (type === "auto") return null;
|
||||
const ratio = width / Math.max(1, height);
|
||||
const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio);
|
||||
const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28;
|
||||
return (
|
||||
<span className="grid h-8 w-10 place-items-center">
|
||||
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
|
||||
</span>
|
||||
);
|
||||
if (type === "auto") return null;
|
||||
const ratio = width / Math.max(1, height);
|
||||
const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio);
|
||||
const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28;
|
||||
return (
|
||||
<span className="grid h-8 w-10 place-items-center">
|
||||
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||
return <div className="text-xs font-medium" style={{ color }}>{children}</div>;
|
||||
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;
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function readSizeDimensions(size: string, fallback: { width: number; height: number }) {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
return {
|
||||
width: match ? Number(match[1]) : fallback.width,
|
||||
height: match ? Number(match[2]) : fallback.height,
|
||||
};
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
return {
|
||||
width: match ? Number(match[1]) : fallback.width,
|
||||
height: match ? Number(match[2]) : fallback.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,149 +6,133 @@ 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;
|
||||
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 } };
|
||||
}
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
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;
|
||||
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 },
|
||||
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,
|
||||
});
|
||||
};
|
||||
}, [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 (
|
||||
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
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,87 +5,89 @@ import { Button, Modal, Segmented, Slider } from "antd";
|
||||
import { RotateCcw, WandSparkles } from "lucide-react";
|
||||
|
||||
export type CanvasImageAngleParams = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
const defaultParams: CanvasImageAngleParams = {
|
||||
horizontalAngle: 0,
|
||||
pitchAngle: 9,
|
||||
cameraDistance: 4.8,
|
||||
wideAngle: false,
|
||||
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);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
if (open) setParams(defaultParams);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
</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 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))})`;
|
||||
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))})`;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ 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;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type DragMode = "move" | "resize";
|
||||
@@ -20,151 +20,149 @@ 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;
|
||||
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) setCrop(defaultCrop);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [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 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);
|
||||
};
|
||||
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="调整裁剪框" />
|
||||
))}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
</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}%` }} />
|
||||
</>
|
||||
);
|
||||
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) };
|
||||
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;
|
||||
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}%` };
|
||||
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` };
|
||||
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));
|
||||
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)}`;
|
||||
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);
|
||||
return b ? gcd(b, a % b) : Math.max(1, a);
|
||||
}
|
||||
|
||||
@@ -3,98 +3,86 @@ 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;
|
||||
prompt: string;
|
||||
referenceImages: ReferenceImage[];
|
||||
textCount: number;
|
||||
imageCount: number;
|
||||
};
|
||||
|
||||
export type NodeGenerationInput = {
|
||||
nodeId: string;
|
||||
type: "text" | "image";
|
||||
title: string;
|
||||
text?: string;
|
||||
image?: ReferenceImage;
|
||||
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));
|
||||
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,
|
||||
};
|
||||
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 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 }];
|
||||
}
|
||||
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 } })),
|
||||
],
|
||||
},
|
||||
];
|
||||
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) }))) };
|
||||
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 || "";
|
||||
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,
|
||||
};
|
||||
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)),
|
||||
];
|
||||
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))];
|
||||
}
|
||||
|
||||
@@ -10,182 +10,198 @@ 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;
|
||||
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,
|
||||
node,
|
||||
viewport,
|
||||
onKeep,
|
||||
onLeave,
|
||||
onInfo,
|
||||
onEditText,
|
||||
onDecreaseFont,
|
||||
onIncreaseFont,
|
||||
onToggleDialog,
|
||||
onGenerateImage,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onSaveAsset,
|
||||
onCrop,
|
||||
onAngle,
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
if (!node) return null;
|
||||
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;
|
||||
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>
|
||||
);
|
||||
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]);
|
||||
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]);
|
||||
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>
|
||||
)}
|
||||
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>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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]" />;
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,106 +15,106 @@ import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "
|
||||
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;
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
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;
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigStore((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 || "");
|
||||
const globalConfig = useConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigStore((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]);
|
||||
useEffect(() => {
|
||||
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||
}, [isEditingExistingContent, node.id]);
|
||||
|
||||
const updatePrompt = (value: string) => {
|
||||
setPrompt(value);
|
||||
if (!isEditingExistingContent) onPromptChange(node.id, value);
|
||||
};
|
||||
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("");
|
||||
};
|
||||
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 ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
|
||||
/>
|
||||
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} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => openConfigDialog(true)}
|
||||
onOpenChange={onImageSettingsOpenChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
)}
|
||||
<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} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => openConfigDialog(true)}
|
||||
onOpenChange={onImageSettingsOpenChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
)}
|
||||
</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>
|
||||
<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";
|
||||
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: node.metadata?.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),
|
||||
};
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||
quality: node.metadata?.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),
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,71 +8,80 @@ 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();
|
||||
};
|
||||
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>
|
||||
);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,22 @@ 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)];
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,56 +8,60 @@ 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;
|
||||
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);
|
||||
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);
|
||||
};
|
||||
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}
|
||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
||||
onOpenChange={setOpen}
|
||||
onSearch={setSearch}
|
||||
onChange={selectSize}
|
||||
onBlur={() => {
|
||||
if (search.trim()) selectSize(search);
|
||||
}}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key === "Enter" && search.trim()) selectSize(search);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
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}
|
||||
popupRender={(menu) => (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={setOpen}
|
||||
onSearch={setSearch}
|
||||
onChange={selectSize}
|
||||
onBlur={() => {
|
||||
if (search.trim()) selectSize(search);
|
||||
}}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key === "Enter" && search.trim()) selectSize(search);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,259 +8,273 @@ 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,
|
||||
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;
|
||||
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) : "";
|
||||
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>
|
||||
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> },
|
||||
]}
|
||||
/>
|
||||
{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>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
id,
|
||||
label,
|
||||
active,
|
||||
hovered,
|
||||
activeStyle,
|
||||
hoverStyle,
|
||||
wrapRef,
|
||||
onTipX,
|
||||
onHover,
|
||||
onClick,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
children,
|
||||
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;
|
||||
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)];
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 }} />;
|
||||
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 };
|
||||
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>
|
||||
);
|
||||
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>;
|
||||
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 "";
|
||||
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;
|
||||
if (!wrap) return 0;
|
||||
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
|
||||
const box = target.getBoundingClientRect();
|
||||
return box.left - wrapBox.left + box.width / 2;
|
||||
}
|
||||
|
||||
@@ -7,68 +7,75 @@ 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;
|
||||
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 };
|
||||
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="删除选中" />
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,216 +7,208 @@ 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;
|
||||
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,
|
||||
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,
|
||||
};
|
||||
document.body.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
});
|
||||
const scaleRef = useRef(viewport.k);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextViewportRef = useRef<ViewportTransform | null>(null);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
|
||||
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
scaleRef.current = viewport.k;
|
||||
}, [viewport.k]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!panState.current.isPanning) return;
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||
setIsSpacePressed(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 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 handlePointerUp = () => {
|
||||
if (!panState.current.isPanning) return;
|
||||
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 (!panState.current.hasMoved) {
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
document.body.style.cursor = "default";
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [onCanvasDeselect, onViewportChange]);
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!panState.current.isPanning) return;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) 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;
|
||||
}
|
||||
|
||||
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
|
||||
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||
}, [containerRef]);
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
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 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)`;
|
||||
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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-40"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
backgroundPosition: `${x}px ${y}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,33 +2,33 @@ import { CanvasNodeType } from "./types";
|
||||
import type { CanvasNodeMetadata } from "./types";
|
||||
|
||||
type CanvasNodeSpec = {
|
||||
width: number;
|
||||
height: number;
|
||||
title: string;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
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: "生成配置" },
|
||||
[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" },
|
||||
},
|
||||
[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];
|
||||
return NODE_SPECS[type];
|
||||
}
|
||||
|
||||
@@ -11,75 +11,89 @@ 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;
|
||||
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 { 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 = "";
|
||||
}
|
||||
};
|
||||
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>
|
||||
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>
|
||||
{!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>
|
||||
);
|
||||
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
|
||||
<CanvasDeleteProjectsDialog />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,27 +7,27 @@ 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;
|
||||
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;
|
||||
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 };
|
||||
@@ -37,89 +37,93 @@ 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),
|
||||
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 = nanoid();
|
||||
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: nanoid(),
|
||||
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 });
|
||||
},
|
||||
},
|
||||
),
|
||||
persist(
|
||||
(set, get) => ({
|
||||
hydrated: false,
|
||||
projects: [],
|
||||
createProject: (title = "未命名画布") => {
|
||||
const now = new Date().toISOString();
|
||||
const id = nanoid();
|
||||
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: nanoid(),
|
||||
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 });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,27 +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;
|
||||
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)) })),
|
||||
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)) })),
|
||||
}));
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type ViewportTransform = {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
};
|
||||
|
||||
export enum CanvasNodeType {
|
||||
Image = "image",
|
||||
Text = "text",
|
||||
Config = "config",
|
||||
Image = "image",
|
||||
Text = "text",
|
||||
Config = "config",
|
||||
}
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
@@ -20,101 +20,100 @@ export type CanvasGenerationMode = "text" | "image";
|
||||
export type CanvasImageGenerationType = "generation" | "edit";
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
status?: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
fontSize?: number;
|
||||
generationMode?: CanvasGenerationMode;
|
||||
generationType?: CanvasImageGenerationType;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
references?: string[];
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
batchUsesReferenceImages?: boolean;
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
inputOrder?: string[];
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
status?: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
fontSize?: number;
|
||||
generationMode?: CanvasGenerationMode;
|
||||
generationType?: CanvasImageGenerationType;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
references?: string[];
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
batchUsesReferenceImages?: boolean;
|
||||
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;
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
position: Position;
|
||||
width: number;
|
||||
height: number;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
};
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type CanvasAssistantReference = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
dataUrl?: string;
|
||||
storageKey?: string;
|
||||
text?: string;
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
dataUrl?: string;
|
||||
storageKey?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type CanvasAssistantImage = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
storageKey?: string;
|
||||
prompt: string;
|
||||
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[];
|
||||
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;
|
||||
id: string;
|
||||
title: string;
|
||||
messages: CanvasAssistantMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConnectionHandle = {
|
||||
nodeId: string;
|
||||
handleType: "source" | "target";
|
||||
nodeId: string;
|
||||
handleType: "source" | "target";
|
||||
};
|
||||
|
||||
export type SelectionBox = {
|
||||
startWorldX: number;
|
||||
startWorldY: number;
|
||||
currentWorldX: number;
|
||||
currentWorldY: number;
|
||||
additive: boolean;
|
||||
initialSelectedNodeIds: string[];
|
||||
startWorldX: number;
|
||||
startWorldY: number;
|
||||
currentWorldX: number;
|
||||
currentWorldY: number;
|
||||
additive: boolean;
|
||||
initialSelectedNodeIds: string[];
|
||||
};
|
||||
|
||||
export type ContextMenuState =
|
||||
{
|
||||
export type ContextMenuState = {
|
||||
type: "node";
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,90 +1,84 @@
|
||||
"use client";
|
||||
|
||||
export type ImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ImageAngleTransform = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
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);
|
||||
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 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;
|
||||
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();
|
||||
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);
|
||||
}
|
||||
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");
|
||||
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");
|
||||
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;
|
||||
});
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
+645
-515
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,10 @@ import type { ReactNode } from "react";
|
||||
import { AppTopNav } from "@/components/layout/app-top-nav";
|
||||
|
||||
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTopNav />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTopNav />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,67 +8,67 @@ import { Suspense } from "react";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type LoginFormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
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 { 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 : "登录失败");
|
||||
}
|
||||
};
|
||||
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>
|
||||
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>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+107
-107
@@ -8,118 +8,118 @@ import { fetchPrompts, type Prompt } from "@/services/api/prompts";
|
||||
import { navigationTools } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Highlighter({
|
||||
action,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
action: "highlight" | "underline";
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="relative inline-block px-1">
|
||||
{action === "highlight" ? (
|
||||
<span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
|
||||
)}
|
||||
<span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
|
||||
</span>
|
||||
);
|
||||
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);
|
||||
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]);
|
||||
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" />
|
||||
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 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>
|
||||
</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>
|
||||
);
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,92 +13,116 @@ import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [titleKeyword, setTitleKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const copyText = useCopyText();
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
||||
const { message } = App.useApp();
|
||||
const [titleKeyword, setTitleKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const copyText = useCopyText();
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}
|
||||
}, [message, query.error, query.isError]);
|
||||
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 toggleTag = (tag: string) => {
|
||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||
};
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
};
|
||||
const 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();
|
||||
}
|
||||
};
|
||||
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>
|
||||
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>
|
||||
<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}
|
||||
|
||||
{!query.isLoading ? (
|
||||
<div>
|
||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{promptItems.map((item) => (
|
||||
<PromptCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={() => setSelectedPrompt(item)}
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
|
||||
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
|
||||
</div>
|
||||
|
||||
{!query.isLoading ? (
|
||||
<div>
|
||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{promptItems.map((item) => (
|
||||
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => copyText(item.prompt, "提示词已复制")} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>加入我的素材</Button>} />
|
||||
))}
|
||||
</div>
|
||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,48 +4,48 @@ export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ path: string[] }>;
|
||||
params: Promise<{ path: string[] }>;
|
||||
};
|
||||
|
||||
function proxyHeaders(request: NextRequest) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("content-length");
|
||||
headers.delete("connection");
|
||||
return headers;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("content-length");
|
||||
headers.delete("connection");
|
||||
return headers;
|
||||
}
|
||||
|
||||
function responseHeaders(response: Response) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-length");
|
||||
headers.delete("content-encoding");
|
||||
headers.delete("transfer-encoding");
|
||||
return headers;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-length");
|
||||
headers.delete("content-encoding");
|
||||
headers.delete("transfer-encoding");
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function proxy(request: NextRequest, context: RouteContext) {
|
||||
const { path } = await context.params;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
|
||||
const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`;
|
||||
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||
const { path } = await context.params;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
|
||||
const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`;
|
||||
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: request.method,
|
||||
headers: proxyHeaders(request),
|
||||
body: hasBody ? request.body : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
} as RequestInit & { duplex?: "half" });
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: request.method,
|
||||
headers: proxyHeaders(request),
|
||||
body: hasBody ? request.body : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
} as RequestInit & { duplex?: "half" });
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders(response),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to proxy", target, error);
|
||||
return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 });
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders(response),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to proxy", target, error);
|
||||
return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxy;
|
||||
|
||||
+378
-373
@@ -5,401 +5,406 @@
|
||||
@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);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
.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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
* {
|
||||
@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;
|
||||
}
|
||||
|
||||
@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);
|
||||
.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;
|
||||
}
|
||||
|
||||
72% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(-1deg) scale(1.015);
|
||||
.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;
|
||||
}
|
||||
|
||||
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);
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||
.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 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
color 0.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;
|
||||
}
|
||||
|
||||
@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) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
+25
-26
@@ -7,35 +7,34 @@ import "./globals.css";
|
||||
import React from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "无限画布",
|
||||
description: "一个无限画布创作工具",
|
||||
title: "无限画布",
|
||||
description: "一个无限画布创作工具",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
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
|
||||
id="theme-script"
|
||||
strategy="beforeInteractive"
|
||||
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){}`,
|
||||
}}
|
||||
/>
|
||||
<AntdRegistry>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</AntdRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
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
|
||||
id="theme-script"
|
||||
strategy="beforeInteractive"
|
||||
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){}`,
|
||||
}}
|
||||
/>
|
||||
<AntdRegistry>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</AntdRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-25
@@ -2,29 +2,28 @@ import { Home, LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">
|
||||
这个地址没有对应的页面,可能已经移动或被合并到其他入口。
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link 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>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">这个地址没有对应的页面,可能已经移动或被合并到其他入口。</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,39 +9,39 @@ 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);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
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);
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,111 +8,125 @@ import { fetchImageModels } from "@/services/api/image";
|
||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export function AppConfigModal() {
|
||||
const { message } = App.useApp();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
|
||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const publicSettings = useConfigStore((state) => state.publicSettings);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const modelChannel = publicSettings?.modelChannel;
|
||||
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
|
||||
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
|
||||
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
|
||||
const { message } = App.useApp();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
|
||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const publicSettings = useConfigStore((state) => state.publicSettings);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const modelChannel = publicSettings?.modelChannel;
|
||||
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
|
||||
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
|
||||
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
|
||||
|
||||
const finishConfig = () => {
|
||||
setConfigDialogOpen(false);
|
||||
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
|
||||
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
|
||||
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
|
||||
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
||||
clearPromptContinue();
|
||||
};
|
||||
const finishConfig = () => {
|
||||
setConfigDialogOpen(false);
|
||||
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
|
||||
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
|
||||
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
|
||||
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
||||
clearPromptContinue();
|
||||
};
|
||||
|
||||
const refreshModels = async () => {
|
||||
if (effectiveMode === "remote") return;
|
||||
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
|
||||
message.error("请先填写 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const models = await fetchImageModels(config);
|
||||
updateConfig("models", models);
|
||||
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
|
||||
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
const refreshModels = async () => {
|
||||
if (effectiveMode === "remote") return;
|
||||
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
|
||||
message.error("请先填写 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const models = await fetchImageModels(config);
|
||||
updateConfig("models", models);
|
||||
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
|
||||
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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={760}
|
||||
centered
|
||||
onCancel={() => setConfigDialogOpen(false)}
|
||||
footer={<Button type="primary" onClick={finishConfig}>完成</Button>}
|
||||
>
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
{allowCustomChannel ? (
|
||||
<Form.Item label="渠道模式" className="mb-4">
|
||||
<Segmented
|
||||
block
|
||||
size="middle"
|
||||
value={effectiveMode}
|
||||
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
|
||||
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{effectiveMode === "local" ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="Base URL" className="mb-4">
|
||||
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-4">
|
||||
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 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>
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div>
|
||||
</div>
|
||||
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>拉取模型列表</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
|
||||
<div className="font-medium text-stone-900 dark:text-stone-100">云端渠道</div>
|
||||
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
||||
}
|
||||
open={isConfigOpen}
|
||||
width={760}
|
||||
centered
|
||||
onCancel={() => setConfigDialogOpen(false)}
|
||||
footer={
|
||||
<Button type="primary" onClick={finishConfig}>
|
||||
完成
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
{allowCustomChannel ? (
|
||||
<Form.Item label="渠道模式" className="mb-4">
|
||||
<Segmented
|
||||
block
|
||||
size="middle"
|
||||
value={effectiveMode}
|
||||
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
|
||||
options={[
|
||||
{ label: "本地直连", value: "local" },
|
||||
{ label: "云端渠道", value: "remote" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{effectiveMode === "local" ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="Base URL" className="mb-4">
|
||||
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-4">
|
||||
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 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 size="small" loading={loadingModels} onClick={() => void refreshModels()}>
|
||||
拉取模型列表
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
|
||||
<div className="font-medium text-stone-900 dark:text-stone-100">云端渠道</div>
|
||||
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="默认生图模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认文本模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{effectiveMode === "local" ? (
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="默认生图模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认文本模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{effectiveMode === "local" ? (
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,33 +12,33 @@ import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const dark = theme === "dark";
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const dark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, [dark, theme]);
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, [dark, theme]);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
|
||||
<ProConfigProvider dark={dark}>
|
||||
<App>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientRootInit>{children}</ClientRootInit>
|
||||
</QueryClientProvider>
|
||||
</App>
|
||||
</ProConfigProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
|
||||
<ProConfigProvider dark={dark}>
|
||||
<App>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientRootInit>{children}</ClientRootInit>
|
||||
</QueryClientProvider>
|
||||
</App>
|
||||
</ProConfigProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,106 +18,102 @@ import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AppTopNav() {
|
||||
const pathname = usePathname();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
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 hideHeader = /^\/canvas\/[^/]+/.test(pathname);
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
const pathname = usePathname();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
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 hideHeader = /^\/canvas\/[^/]+/.test(pathname);
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
<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 />
|
||||
) : (
|
||||
<>
|
||||
<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" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
/>
|
||||
<VersionReleaseModal />
|
||||
<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}
|
||||
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
|
||||
{isReady && user ? (
|
||||
<UserStatusActions />
|
||||
) : (
|
||||
<>
|
||||
<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" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
/>
|
||||
<VersionReleaseModal />
|
||||
<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}
|
||||
|
||||
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
||||
<AppConfigModal />
|
||||
|
||||
</>
|
||||
);
|
||||
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
||||
<AppConfigModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@ import { useConfigStore } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const hydrateUser = useUserStore((state) => state.hydrateUser);
|
||||
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
|
||||
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
|
||||
const pathname = usePathname();
|
||||
const hydrateUser = useUserStore((state) => state.hydrateUser);
|
||||
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
|
||||
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
|
||||
|
||||
useEffect(() => {
|
||||
void loadPublicSettings();
|
||||
}, [loadPublicSettings]);
|
||||
useEffect(() => {
|
||||
void loadPublicSettings();
|
||||
}, [loadPublicSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoginPage) void hydrateUser();
|
||||
}, [hydrateUser, isLoginPage]);
|
||||
useEffect(() => {
|
||||
if (!isLoginPage) void hydrateUser();
|
||||
}, [hydrateUser, isLoginPage]);
|
||||
|
||||
return <>{children}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -5,22 +5,22 @@ import { GithubOutlined } from "@ant-design/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GitHubLinkProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,36 +7,34 @@ import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MobileNavDrawerProps = {
|
||||
open: boolean;
|
||||
activeToolSlug?: NavigationToolSlug;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
activeToolSlug?: NavigationToolSlug;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
|
||||
return (
|
||||
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} 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={onClose}
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} 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={onClose}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,90 +16,60 @@ import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type UserStatusActionsProps = {
|
||||
showConfig?: boolean;
|
||||
variant?: "default" | "canvas";
|
||||
onOpenShortcuts?: () => void;
|
||||
accountOpen?: boolean;
|
||||
onAccountOpenChange?: (open: boolean) => void;
|
||||
accountRef?: RefObject<HTMLDivElement | null>;
|
||||
getPopupContainer?: (node: HTMLElement) => HTMLElement;
|
||||
showConfig?: boolean;
|
||||
variant?: "default" | "canvas";
|
||||
onOpenShortcuts?: () => void;
|
||||
accountOpen?: boolean;
|
||||
onAccountOpenChange?: (open: boolean) => void;
|
||||
accountRef?: RefObject<HTMLDivElement | null>;
|
||||
getPopupContainer?: (node: HTMLElement) => HTMLElement;
|
||||
};
|
||||
|
||||
export function UserStatusActions({
|
||||
showConfig = true,
|
||||
variant = "default",
|
||||
onOpenShortcuts,
|
||||
accountOpen,
|
||||
onAccountOpenChange,
|
||||
accountRef,
|
||||
getPopupContainer,
|
||||
}: UserStatusActionsProps) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const canvasTheme = canvasThemes[theme];
|
||||
const userName = user?.username || "用户";
|
||||
const avatarText = (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";
|
||||
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
|
||||
const versionStyle = iconStyle;
|
||||
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
|
||||
const gitHubStyle = iconStyle;
|
||||
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
|
||||
const menuItems: ItemType[] = [
|
||||
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
|
||||
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
||||
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
|
||||
{ type: "divider" },
|
||||
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
|
||||
];
|
||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const canvasTheme = canvasThemes[theme];
|
||||
const userName = user?.username || "用户";
|
||||
const avatarText = (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";
|
||||
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
|
||||
const versionStyle = iconStyle;
|
||||
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
|
||||
const gitHubStyle = iconStyle;
|
||||
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
|
||||
const menuItems: ItemType[] = [
|
||||
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
|
||||
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
||||
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
|
||||
{ type: "divider" },
|
||||
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1.5">
|
||||
{showConfig ? (
|
||||
<button
|
||||
type="button"
|
||||
className={naturalIconClass}
|
||||
style={iconStyle}
|
||||
onClick={() => openConfigDialog(false)}
|
||||
aria-label="配置"
|
||||
title="配置"
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<AnimatedThemeToggler
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
className={naturalIconClass}
|
||||
style={iconStyle}
|
||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
/>
|
||||
<VersionReleaseModal style={versionStyle} />
|
||||
<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="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"
|
||||
style={avatarStyle}
|
||||
aria-label="账户菜单"
|
||||
>
|
||||
<span className="leading-none">{avatarText}</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1.5">
|
||||
{showConfig ? (
|
||||
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
|
||||
<Settings2 className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
|
||||
<VersionReleaseModal style={versionStyle} />
|
||||
<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="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"
|
||||
style={avatarStyle}
|
||||
aria-label="账户菜单"
|
||||
>
|
||||
<span className="leading-none">{avatarText}</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,93 +6,88 @@ import { useVersionCheck } from "@/hooks/use-version-check";
|
||||
import { APP_VERSION } from "@/constant/env";
|
||||
|
||||
function getTagColor(type: string) {
|
||||
if (type === "新增") return "green";
|
||||
if (type === "修复") return "red";
|
||||
if (type === "调整") return "blue";
|
||||
if (type === "文档") return "purple";
|
||||
return "default";
|
||||
if (type === "新增") return "green";
|
||||
if (type === "修复") return "red";
|
||||
if (type === "调整") return "blue";
|
||||
if (type === "文档") return "purple";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function getReleaseTitle(version: string) {
|
||||
return version === "Unreleased" ? "未发布" : version;
|
||||
return version === "Unreleased" ? "未发布" : version;
|
||||
}
|
||||
|
||||
type VersionReleaseModalProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
|
||||
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
|
||||
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
|
||||
style={style}
|
||||
onClick={openReleaseModal}
|
||||
title="查看版本更新"
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
{APP_VERSION}
|
||||
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
<Modal
|
||||
title="版本更新"
|
||||
open={open}
|
||||
width={680}
|
||||
centered
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
|
||||
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
|
||||
<button
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
|
||||
onClick={() => void checkLatestRelease(true)}
|
||||
>
|
||||
{checking ? "检查中..." : "检查更新"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[56vh] overflow-y-auto pr-2">
|
||||
<Timeline
|
||||
items={releases.map((release) => ({
|
||||
content: (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
|
||||
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
|
||||
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
|
||||
style={style}
|
||||
onClick={openReleaseModal}
|
||||
title="查看版本更新"
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
{APP_VERSION}
|
||||
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
<Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
|
||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
|
||||
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
|
||||
onClick={() => void checkLatestRelease(true)}
|
||||
>
|
||||
{checking ? "检查中..." : "检查更新"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{release.items.map((item, index) => (
|
||||
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
||||
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
|
||||
<span className="min-w-0 flex-1">{item.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
<div className="max-h-[56vh] overflow-y-auto pr-2">
|
||||
<Timeline
|
||||
items={releases.map((release) => ({
|
||||
content: (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
|
||||
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{release.items.map((item, index) => (
|
||||
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
||||
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
|
||||
{item.type}
|
||||
</Tag>
|
||||
<span className="min-w-0 flex-1">{item.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
.dropdown :global(.ant-select-item) {
|
||||
font-weight: 400;
|
||||
min-height: 34px;
|
||||
padding-inline: 10px 28px;
|
||||
font-weight: 400;
|
||||
min-height: 34px;
|
||||
padding-inline: 10px 28px;
|
||||
}
|
||||
|
||||
.dropdown :global(.ant-select-item-option-selected) {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-holder) {
|
||||
scrollbar-width: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar,
|
||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar-button {
|
||||
height: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-scrollbar) {
|
||||
right: 4px !important;
|
||||
width: 2px !important;
|
||||
right: 4px !important;
|
||||
width: 2px !important;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-scrollbar-thumb) {
|
||||
background: rgba(120, 113, 108, 0.42) !important;
|
||||
border-radius: 999px !important;
|
||||
background: rgba(120, 113, 108, 0.42) !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
@@ -6,51 +6,64 @@ import styles from "./model-picker.module.css";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
value?: string;
|
||||
onChange: (model: string) => void;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
placeholder?: string;
|
||||
onMissingConfig?: () => void;
|
||||
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: <ModelLabel model={model} /> }));
|
||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
||||
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: <ModelLabel model={model} /> }));
|
||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
className={`canvas-control-select ${className || ""}`}
|
||||
classNames={{ popup: { root: styles.dropdown } }}
|
||||
popupMatchSelectWidth
|
||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
||||
value={value || undefined}
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
notFoundContent="请先到配置里拉取模型列表"
|
||||
onChange={onChange}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (!options.length) onMissingConfig?.();
|
||||
}}
|
||||
filterOption={(input, option) => String(option?.value || "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
className={`canvas-control-select ${className || ""}`}
|
||||
classNames={{ popup: { root: styles.dropdown } }}
|
||||
popupMatchSelectWidth
|
||||
popupRender={(menu) => (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
||||
value={value || undefined}
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
notFoundContent="请先到配置里拉取模型列表"
|
||||
onChange={onChange}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (!options.length) onMissingConfig?.();
|
||||
}}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelLabel({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(model);
|
||||
return <span className="flex min-w-0 items-center gap-2">{icon && <img src={icon} alt="" className="size-4 shrink-0" />}<span className="truncate">{model}</span></span>;
|
||||
const icon = resolveModelIcon(model);
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{icon && <img src={icon} alt="" className="size-4 shrink-0" />}
|
||||
<span className="truncate">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelIcon(model: string) {
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||
return "";
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -6,30 +6,56 @@ 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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,29 +6,43 @@ 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>
|
||||
</>
|
||||
);
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,71 +10,79 @@ 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);
|
||||
};
|
||||
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]);
|
||||
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();
|
||||
};
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@ 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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,269 +1,194 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
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
|
||||
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})`
|
||||
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`
|
||||
)
|
||||
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})`];
|
||||
}
|
||||
return `polygon(${verts.join(", ")})`
|
||||
}
|
||||
const startR = Math.max(2, R * 0.025)
|
||||
return [starPolygon(startR), starPolygon(R)]
|
||||
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)`];
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setIsDark(theme === "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
updateTheme()
|
||||
updateTheme();
|
||||
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [theme])
|
||||
return () => observer.disconnect();
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
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
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
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 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 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])
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,263 +1,228 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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 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
|
||||
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
|
||||
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 >= 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)}%`)
|
||||
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)}%`)
|
||||
})
|
||||
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%`)
|
||||
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
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
|
||||
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 string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
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 string’s 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()
|
||||
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 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 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 [activeIndex, setActiveIndex] = useState(0);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
const sweepPos = useMotionValue(SWEEP_START);
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
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])
|
||||
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
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
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)
|
||||
},
|
||||
})
|
||||
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()
|
||||
}
|
||||
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()
|
||||
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])
|
||||
return () => {
|
||||
stopRef.current?.();
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: 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>
|
||||
)
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +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,
|
||||
},
|
||||
{
|
||||
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"];
|
||||
|
||||
@@ -4,10 +4,10 @@ import { App } from "antd";
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
export function useCopyText() {
|
||||
const { message } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
|
||||
return (value: string, successText = "已复制") => {
|
||||
copy(value);
|
||||
message.success(successText);
|
||||
};
|
||||
return (value: string, successText = "已复制") => {
|
||||
copy(value);
|
||||
message.success(successText);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,88 +7,88 @@ const latestVersionUrl = "https://raw.githubusercontent.com/basketikun/infinite-
|
||||
const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md";
|
||||
|
||||
function readLocalReleases(): ReleaseInfo[] {
|
||||
try {
|
||||
return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toVersionParts(version: string) {
|
||||
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
function isNewerVersion(latestVersion: string, currentVersion: string) {
|
||||
const latest = toVersionParts(latestVersion);
|
||||
const current = toVersionParts(currentVersion);
|
||||
if (!latest || !current) return false;
|
||||
return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]));
|
||||
const latest = toVersionParts(latestVersion);
|
||||
const current = toVersionParts(currentVersion);
|
||||
if (!latest || !current) return false;
|
||||
return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]));
|
||||
}
|
||||
|
||||
export function useVersionCheck() {
|
||||
const currentVersion = APP_VERSION;
|
||||
const { message } = App.useApp();
|
||||
const localReleases = useMemo(readLocalReleases, []);
|
||||
const [latestVersion, setLatestVersion] = useState(currentVersion);
|
||||
const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
|
||||
const currentVersion = APP_VERSION;
|
||||
const { message } = App.useApp();
|
||||
const localReleases = useMemo(readLocalReleases, []);
|
||||
const [latestVersion, setLatestVersion] = useState(currentVersion);
|
||||
const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
|
||||
|
||||
const checkLatestVersion = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(latestVersionUrl);
|
||||
if (!response.ok) return false;
|
||||
const version = await response.text();
|
||||
setLatestVersion(version.trim() || currentVersion);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [currentVersion]);
|
||||
const checkLatestVersion = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(latestVersionUrl);
|
||||
if (!response.ok) return false;
|
||||
const version = await response.text();
|
||||
setLatestVersion(version.trim() || currentVersion);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [currentVersion]);
|
||||
|
||||
const checkLatestRelease = useCallback(async (showMessage = false) => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const [versionResponse, changelogResponse] = await Promise.all([
|
||||
fetch(latestVersionUrl),
|
||||
fetch(latestChangelogUrl),
|
||||
]);
|
||||
if (!versionResponse.ok) throw new Error("版本读取失败");
|
||||
if (!changelogResponse.ok) throw new Error("更新日志读取失败");
|
||||
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
|
||||
setLatestVersion(version.trim() || currentVersion);
|
||||
if (changelog.trim()) setReleases(parseChangelog(changelog));
|
||||
if (showMessage) message.success("已获取最新版本信息");
|
||||
return true;
|
||||
} catch {
|
||||
setLatestVersion(currentVersion);
|
||||
setReleases(localReleases);
|
||||
if (showMessage) message.error("获取最新版本信息失败");
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [currentVersion, localReleases, message]);
|
||||
const checkLatestRelease = useCallback(
|
||||
async (showMessage = false) => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]);
|
||||
if (!versionResponse.ok) throw new Error("版本读取失败");
|
||||
if (!changelogResponse.ok) throw new Error("更新日志读取失败");
|
||||
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
|
||||
setLatestVersion(version.trim() || currentVersion);
|
||||
if (changelog.trim()) setReleases(parseChangelog(changelog));
|
||||
if (showMessage) message.success("已获取最新版本信息");
|
||||
return true;
|
||||
} catch {
|
||||
setLatestVersion(currentVersion);
|
||||
setReleases(localReleases);
|
||||
if (showMessage) message.error("获取最新版本信息失败");
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
},
|
||||
[currentVersion, localReleases, message],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void checkLatestVersion();
|
||||
}, [checkLatestVersion]);
|
||||
useEffect(() => {
|
||||
void checkLatestVersion();
|
||||
}, [checkLatestVersion]);
|
||||
|
||||
const openReleaseModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
void checkLatestRelease();
|
||||
}, [checkLatestRelease]);
|
||||
const openReleaseModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
void checkLatestRelease();
|
||||
}, [checkLatestRelease]);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
openReleaseModal,
|
||||
latestVersion,
|
||||
releases,
|
||||
checking,
|
||||
hasNewVersion,
|
||||
checkLatestRelease,
|
||||
};
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
openReleaseModal,
|
||||
latestVersion,
|
||||
releases,
|
||||
checking,
|
||||
hasNewVersion,
|
||||
checkLatestRelease,
|
||||
};
|
||||
}
|
||||
|
||||
+65
-65
@@ -3,76 +3,76 @@ import type { ThemeConfig } from "antd";
|
||||
import { theme as antdTheme } from "antd";
|
||||
|
||||
const neutral = {
|
||||
light: {
|
||||
primary: "#171717",
|
||||
primaryHover: "#000000",
|
||||
primaryText: "#ffffff",
|
||||
menuBg: "#f5f5f5",
|
||||
menuText: "#171717",
|
||||
selectActiveBg: "#f5f5f5",
|
||||
selectSelectedBg: "#f0f0f0",
|
||||
selectText: "#171717",
|
||||
tableSelectedBg: "rgba(17, 17, 17, 0.05)",
|
||||
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
|
||||
},
|
||||
dark: {
|
||||
primary: "#fafafa",
|
||||
primaryHover: "#ffffff",
|
||||
primaryText: "#171717",
|
||||
menuBg: "#262626",
|
||||
menuText: "#fafafa",
|
||||
selectActiveBg: "#262626",
|
||||
selectSelectedBg: "#333333",
|
||||
selectText: "#fafafa",
|
||||
tableSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||
tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)",
|
||||
},
|
||||
light: {
|
||||
primary: "#171717",
|
||||
primaryHover: "#000000",
|
||||
primaryText: "#ffffff",
|
||||
menuBg: "#f5f5f5",
|
||||
menuText: "#171717",
|
||||
selectActiveBg: "#f5f5f5",
|
||||
selectSelectedBg: "#f0f0f0",
|
||||
selectText: "#171717",
|
||||
tableSelectedBg: "rgba(17, 17, 17, 0.05)",
|
||||
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
|
||||
},
|
||||
dark: {
|
||||
primary: "#fafafa",
|
||||
primaryHover: "#ffffff",
|
||||
primaryText: "#171717",
|
||||
menuBg: "#262626",
|
||||
menuText: "#fafafa",
|
||||
selectActiveBg: "#262626",
|
||||
selectSelectedBg: "#333333",
|
||||
selectText: "#fafafa",
|
||||
tableSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||
tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)",
|
||||
},
|
||||
};
|
||||
|
||||
export const adminLayoutStyle = {
|
||||
siderWidth: 232,
|
||||
headerHeight: 56,
|
||||
brandHeight: 64,
|
||||
menu: { borderInlineEnd: 0, padding: "18px 12px", fontSize: 15 } satisfies CSSProperties,
|
||||
menuItem: { height: 44, lineHeight: "44px", marginBlock: 4, borderRadius: 8 } satisfies CSSProperties,
|
||||
siderWidth: 232,
|
||||
headerHeight: 56,
|
||||
brandHeight: 64,
|
||||
menu: { borderInlineEnd: 0, padding: "18px 12px", fontSize: 15 } satisfies CSSProperties,
|
||||
menuItem: { height: 44, lineHeight: "44px", marginBlock: 4, borderRadius: 8 } satisfies CSSProperties,
|
||||
};
|
||||
|
||||
export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
const color = dark ? neutral.dark : neutral.light;
|
||||
const color = dark ? neutral.dark : neutral.light;
|
||||
|
||||
return {
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" },
|
||||
token: {
|
||||
colorPrimary: color.primary,
|
||||
colorInfo: color.primary,
|
||||
colorLink: color.primary,
|
||||
colorLinkHover: color.primaryHover,
|
||||
colorLinkActive: color.primary,
|
||||
colorTextLightSolid: color.primaryText,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: "none",
|
||||
},
|
||||
Menu: {
|
||||
itemActiveBg: color.menuBg,
|
||||
itemHoverBg: color.menuBg,
|
||||
itemSelectedBg: color.menuBg,
|
||||
itemSelectedColor: color.menuText,
|
||||
darkItemHoverBg: neutral.dark.menuBg,
|
||||
darkItemSelectedBg: neutral.dark.menuBg,
|
||||
darkItemSelectedColor: neutral.dark.menuText,
|
||||
},
|
||||
Select: {
|
||||
optionActiveBg: color.selectActiveBg,
|
||||
optionSelectedBg: color.selectSelectedBg,
|
||||
optionSelectedColor: color.selectText,
|
||||
},
|
||||
Table: {
|
||||
rowSelectedBg: color.tableSelectedBg,
|
||||
rowSelectedHoverBg: color.tableSelectedHoverBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" },
|
||||
token: {
|
||||
colorPrimary: color.primary,
|
||||
colorInfo: color.primary,
|
||||
colorLink: color.primary,
|
||||
colorLinkHover: color.primaryHover,
|
||||
colorLinkActive: color.primary,
|
||||
colorTextLightSolid: color.primaryText,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: "none",
|
||||
},
|
||||
Menu: {
|
||||
itemActiveBg: color.menuBg,
|
||||
itemHoverBg: color.menuBg,
|
||||
itemSelectedBg: color.menuBg,
|
||||
itemSelectedColor: color.menuText,
|
||||
darkItemHoverBg: neutral.dark.menuBg,
|
||||
darkItemSelectedBg: neutral.dark.menuBg,
|
||||
darkItemSelectedColor: neutral.dark.menuText,
|
||||
},
|
||||
Select: {
|
||||
optionActiveBg: color.selectActiveBg,
|
||||
optionSelectedBg: color.selectSelectedBg,
|
||||
optionSelectedColor: color.selectText,
|
||||
},
|
||||
Table: {
|
||||
rowSelectedBg: color.tableSelectedBg,
|
||||
rowSelectedHoverBg: color.tableSelectedHoverBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+54
-54
@@ -2,62 +2,62 @@ 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)",
|
||||
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",
|
||||
},
|
||||
},
|
||||
node: {
|
||||
label: "#57534e",
|
||||
fill: "#e7e5df",
|
||||
panel: "#fbfaf7",
|
||||
stroke: "#d6d3ca",
|
||||
activeStroke: "#1c1917",
|
||||
placeholder: "#8a8479",
|
||||
text: "#292524",
|
||||
muted: "#78716c",
|
||||
faint: "#a8a29e",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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];
|
||||
|
||||
+43
-43
@@ -1,62 +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]}`;
|
||||
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}秒`;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
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;
|
||||
});
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -2,33 +2,33 @@ import localforage from "localforage";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
|
||||
localforage.config({
|
||||
name: "infinite-canvas",
|
||||
storeName: "app_state",
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+19
-19
@@ -1,24 +1,24 @@
|
||||
export type ReleaseInfo = {
|
||||
version: string;
|
||||
date: string;
|
||||
items: { type: string; content: string }[];
|
||||
version: string;
|
||||
date: string;
|
||||
items: { type: string; content: string }[];
|
||||
};
|
||||
|
||||
export function parseChangelog(content: string): ReleaseInfo[] {
|
||||
return content
|
||||
.split(/^## /m)
|
||||
.slice(1)
|
||||
.map((block) => {
|
||||
const [title = "", ...lines] = block.trim().split("\n");
|
||||
const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
|
||||
return {
|
||||
version: version.trim(),
|
||||
date: date.trim(),
|
||||
items: lines
|
||||
.map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => ({ type: match[1], content: match[2] })),
|
||||
};
|
||||
})
|
||||
.filter((release) => release.items.length);
|
||||
return content
|
||||
.split(/^## /m)
|
||||
.slice(1)
|
||||
.map((block) => {
|
||||
const [title = "", ...lines] = block.trim().split("\n");
|
||||
const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
|
||||
return {
|
||||
version: version.trim(),
|
||||
date: date.trim(),
|
||||
items: lines
|
||||
.map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => ({ type: match[1], content: match[2] })),
|
||||
};
|
||||
})
|
||||
.filter((release) => release.items.length);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -2,145 +2,141 @@ import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/req
|
||||
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
|
||||
|
||||
export type AdminPromptCategory = {
|
||||
category: string;
|
||||
name: string;
|
||||
description: string;
|
||||
file: string;
|
||||
githubUrl: string;
|
||||
remote: boolean;
|
||||
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);
|
||||
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);
|
||||
return apiPost<AdminPromptCategory[]>("/api/admin/prompt-categories/sync", { category }, token);
|
||||
}
|
||||
|
||||
export type AdminPromptQuery = {
|
||||
keyword?: string;
|
||||
category?: string;
|
||||
tag?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
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;
|
||||
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;
|
||||
items: AdminAsset[];
|
||||
tags: string[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
|
||||
return apiGet<PromptListResponse>("/api/admin/prompts", compactApiParams(query), token);
|
||||
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);
|
||||
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);
|
||||
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
|
||||
}
|
||||
|
||||
export async function deleteAdminPrompts(token: string, ids: string[]) {
|
||||
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
|
||||
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
|
||||
}
|
||||
|
||||
export type AdminAssetQuery = {
|
||||
keyword?: string;
|
||||
type?: string;
|
||||
tag?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
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,
|
||||
);
|
||||
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);
|
||||
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);
|
||||
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
|
||||
}
|
||||
|
||||
export type AdminModelChannel = {
|
||||
protocol: "openai";
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
weight: number;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
protocol: "openai";
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
weight: number;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export type AdminPublicModelChannelSettings = {
|
||||
availableModels: string[];
|
||||
defaultModel: string;
|
||||
defaultImageModel: string;
|
||||
defaultTextModel: string;
|
||||
systemPrompt: string;
|
||||
allowCustomChannel: boolean;
|
||||
availableModels: string[];
|
||||
defaultModel: string;
|
||||
defaultImageModel: string;
|
||||
defaultTextModel: string;
|
||||
systemPrompt: string;
|
||||
allowCustomChannel: boolean;
|
||||
};
|
||||
|
||||
export type AdminPublicSettings = {
|
||||
modelChannel: AdminPublicModelChannelSettings;
|
||||
modelChannel: AdminPublicModelChannelSettings;
|
||||
};
|
||||
|
||||
export type AdminPrivateSettings = {
|
||||
channels: AdminModelChannel[];
|
||||
promptSync: {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
};
|
||||
channels: AdminModelChannel[];
|
||||
promptSync: {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminSettings = {
|
||||
public: AdminPublicSettings;
|
||||
private: AdminPrivateSettings;
|
||||
public: AdminPublicSettings;
|
||||
private: AdminPrivateSettings;
|
||||
};
|
||||
|
||||
export async function fetchAdminSettings(token: string) {
|
||||
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
|
||||
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
|
||||
}
|
||||
|
||||
export async function saveAdminSettings(token: string, settings: AdminSettings) {
|
||||
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
||||
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
||||
}
|
||||
|
||||
export type AdminChannelActionRequest = {
|
||||
index?: number;
|
||||
channel: AdminModelChannel;
|
||||
model?: string;
|
||||
index?: number;
|
||||
channel: AdminModelChannel;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
|
||||
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
|
||||
}
|
||||
|
||||
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
|
||||
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
|
||||
}
|
||||
|
||||
@@ -1,33 +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;
|
||||
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;
|
||||
items: AssetLibraryItem[];
|
||||
tags: string[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AssetLibraryQuery = {
|
||||
keyword?: string;
|
||||
type?: string;
|
||||
tag?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
type?: string;
|
||||
tag?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
|
||||
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
|
||||
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
|
||||
}
|
||||
|
||||
@@ -5,31 +5,31 @@ 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;
|
||||
id: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type AuthPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function login(payload: AuthPayload) {
|
||||
return apiPost<AuthSession>("/api/admin/login", payload);
|
||||
return apiPost<AuthSession>("/api/admin/login", payload);
|
||||
}
|
||||
|
||||
export async function register(payload: AuthPayload) {
|
||||
return apiPost<AuthSession>("/api/auth/register", payload);
|
||||
return apiPost<AuthSession>("/api/auth/register", payload);
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(token?: string) {
|
||||
return apiGet<AuthUser>("/api/auth/me", undefined, token);
|
||||
return apiGet<AuthUser>("/api/auth/me", undefined, token);
|
||||
}
|
||||
|
||||
+163
-158
@@ -7,207 +7,212 @@ import { imageToDataUrl } from "@/services/image-storage";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
export type ChatCompletionMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
|
||||
role: "system" | "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 };
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: Array<Record<string, unknown>>;
|
||||
error?: { message?: string };
|
||||
code?: number;
|
||||
msg?: 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;
|
||||
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) {
|
||||
if (typeof payload.code === "number" && payload.code !== 0) {
|
||||
throw new Error(payload.msg || "请求失败");
|
||||
}
|
||||
const images =
|
||||
payload.data
|
||||
?.map(resolveImageDataUrl)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
|
||||
if (typeof payload.code === "number" && payload.code !== 0) {
|
||||
throw new Error(payload.msg || "请求失败");
|
||||
}
|
||||
const images =
|
||||
payload.data
|
||||
?.map(resolveImageDataUrl)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
|
||||
|
||||
if (images.length === 0) {
|
||||
throw new Error("接口没有返回图片");
|
||||
}
|
||||
if (images.length === 0) {
|
||||
throw new Error("接口没有返回图片");
|
||||
}
|
||||
|
||||
return images;
|
||||
return images;
|
||||
}
|
||||
|
||||
function readAxiosError(error: unknown, fallback: string) {
|
||||
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
|
||||
const responseData = error.response?.data;
|
||||
return responseData?.msg || responseData?.error?.message || (error.response?.status ? `${fallback}:${error.response.status}` : fallback);
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
|
||||
const responseData = error.response?.data;
|
||||
return responseData?.msg || responseData?.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);
|
||||
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);
|
||||
}
|
||||
|
||||
function withSystemPrompt(config: AiConfig, prompt: string) {
|
||||
const systemPrompt = config.systemPrompt.trim();
|
||||
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
|
||||
const systemPrompt = config.systemPrompt.trim();
|
||||
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
|
||||
}
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
|
||||
function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
return config.channelMode === "remote"
|
||||
? contentType ? { "Content-Type": contentType } : undefined
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
return config.channelMode === "remote"
|
||||
? contentType
|
||||
? { "Content-Type": contentType }
|
||||
: undefined
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
|
||||
const systemPrompt = config.systemPrompt.trim();
|
||||
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
|
||||
const systemPrompt = config.systemPrompt.trim();
|
||||
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
|
||||
}
|
||||
|
||||
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>(
|
||||
aiApiUrl(config, "/images/generations"),
|
||||
{
|
||||
model: config.model,
|
||||
prompt: withSystemPrompt(config, prompt),
|
||||
n,
|
||||
quality: config.quality || undefined,
|
||||
size: config.size || undefined,
|
||||
response_format: "b64_json",
|
||||
},
|
||||
{
|
||||
headers: aiHeaders(config, "application/json"),
|
||||
},
|
||||
);
|
||||
return parseImagePayload(response.data);
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(
|
||||
aiApiUrl(config, "/images/generations"),
|
||||
{
|
||||
model: config.model,
|
||||
prompt: withSystemPrompt(config, prompt),
|
||||
n,
|
||||
quality: config.quality || undefined,
|
||||
size: config.size || undefined,
|
||||
response_format: "b64_json",
|
||||
},
|
||||
{
|
||||
headers: aiHeaders(config, "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", withSystemPrompt(config, 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));
|
||||
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", withSystemPrompt(config, 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>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
||||
return parseImagePayload(response.data);
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
||||
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;
|
||||
let buffer = "";
|
||||
let answer = "";
|
||||
let processedLength = 0;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
aiApiUrl(config, "/chat/completions"),
|
||||
{
|
||||
model: config.model,
|
||||
messages: withSystemMessage(config, messages),
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...aiHeaders(config, "application/json"),
|
||||
} as Record<string, string>,
|
||||
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 (typeof response.data === "object" && response.data && "code" in response.data && (response.data as { code?: number; msg?: string }).code !== 0) {
|
||||
throw new Error((response.data as { msg?: string }).msg || "请求失败");
|
||||
}
|
||||
if (typeof response.data === "string") {
|
||||
let apiError = "";
|
||||
try {
|
||||
const payload = JSON.parse(response.data) as { code?: number; msg?: string };
|
||||
if (typeof payload.code === "number" && payload.code !== 0) {
|
||||
apiError = payload.msg || "请求失败";
|
||||
try {
|
||||
const response = await axios.post(
|
||||
aiApiUrl(config, "/chat/completions"),
|
||||
{
|
||||
model: config.model,
|
||||
messages: withSystemMessage(config, messages),
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...aiHeaders(config, "application/json"),
|
||||
} as Record<string, string>,
|
||||
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 (typeof response.data === "object" && response.data && "code" in response.data && (response.data as { code?: number; msg?: string }).code !== 0) {
|
||||
throw new Error((response.data as { msg?: string }).msg || "请求失败");
|
||||
}
|
||||
} catch {
|
||||
// ignore plain text stream content
|
||||
}
|
||||
if (apiError) throw new Error(apiError);
|
||||
if (typeof response.data === "string") {
|
||||
let apiError = "";
|
||||
try {
|
||||
const payload = JSON.parse(response.data) as { code?: number; msg?: string };
|
||||
if (typeof payload.code === "number" && payload.code !== 0) {
|
||||
apiError = payload.msg || "请求失败";
|
||||
}
|
||||
} catch {
|
||||
// ignore plain text stream content
|
||||
}
|
||||
if (apiError) throw new Error(apiError);
|
||||
}
|
||||
if (buffer) {
|
||||
parseStreamChunk(buffer, (delta) => {
|
||||
answer += delta;
|
||||
onDelta(answer);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
if (buffer) {
|
||||
parseStreamChunk(buffer, (delta) => {
|
||||
answer += delta;
|
||||
onDelta(answer);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
return answer || "没有返回内容";
|
||||
return answer || "没有返回内容";
|
||||
}
|
||||
|
||||
export async function fetchImageModels(config: AiConfig) {
|
||||
if (config.channelMode === "remote") return config.models;
|
||||
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, "读取模型失败"));
|
||||
}
|
||||
if (config.channelMode === "remote") return config.models;
|
||||
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, "读取模型失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
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;
|
||||
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;
|
||||
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 } : {}),
|
||||
}));
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,81 +3,79 @@ import axios from "axios";
|
||||
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
msg: string;
|
||||
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;
|
||||
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;
|
||||
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,
|
||||
});
|
||||
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}` } : {}),
|
||||
},
|
||||
});
|
||||
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,
|
||||
});
|
||||
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("接口连接失败,请确认后端服务已启动");
|
||||
}
|
||||
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 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 || "请求失败");
|
||||
}
|
||||
const payload = result as ApiResponse<T>;
|
||||
if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
|
||||
throw new Error(payload.msg || "请求失败");
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
@@ -6,74 +6,76 @@ import { nanoid } from "nanoid";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
|
||||
export type UploadedImage = {
|
||||
url: string;
|
||||
storageKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
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:${nanoid()}`;
|
||||
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 };
|
||||
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
|
||||
const storageKey = `image:${nanoid()}`;
|
||||
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;
|
||||
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());
|
||||
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);
|
||||
}));
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
});
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,79 +13,84 @@ export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storage
|
||||
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>;
|
||||
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;
|
||||
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),
|
||||
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 = nanoid();
|
||||
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"],
|
||||
},
|
||||
),
|
||||
persist(
|
||||
(set, get) => ({
|
||||
assets: [],
|
||||
addAsset: (asset) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = nanoid();
|
||||
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"],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,116 +8,116 @@ import { apiGet } from "@/services/api/request";
|
||||
import type { AdminPublicSettings } from "@/services/api/admin";
|
||||
|
||||
export type AiConfig = {
|
||||
channelMode: "remote" | "local";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
imageModel: string;
|
||||
textModel: string;
|
||||
systemPrompt: string;
|
||||
models: string[];
|
||||
quality: string;
|
||||
size: string;
|
||||
count: string;
|
||||
channelMode: "remote" | "local";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
imageModel: string;
|
||||
textModel: string;
|
||||
systemPrompt: string;
|
||||
models: string[];
|
||||
quality: string;
|
||||
size: string;
|
||||
count: string;
|
||||
};
|
||||
|
||||
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
|
||||
|
||||
export const defaultConfig: AiConfig = {
|
||||
channelMode: "local",
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "",
|
||||
model: "gpt-image-2",
|
||||
imageModel: "gpt-image-2",
|
||||
textModel: "gpt-5.5",
|
||||
systemPrompt: "",
|
||||
models: [],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
count: "1",
|
||||
channelMode: "local",
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "",
|
||||
model: "gpt-image-2",
|
||||
imageModel: "gpt-image-2",
|
||||
textModel: "gpt-5.5",
|
||||
systemPrompt: "",
|
||||
models: [],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
count: "1",
|
||||
};
|
||||
|
||||
type ConfigStore = {
|
||||
config: AiConfig;
|
||||
publicSettings: AdminPublicSettings | null;
|
||||
isPublicSettingsLoading: boolean;
|
||||
isConfigOpen: boolean;
|
||||
shouldPromptContinue: boolean;
|
||||
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
loadPublicSettings: () => Promise<void>;
|
||||
isAiConfigReady: (config: AiConfig, model: string) => boolean;
|
||||
openConfigDialog: (shouldPromptContinue?: boolean) => void;
|
||||
setConfigDialogOpen: (isOpen: boolean) => void;
|
||||
clearPromptContinue: () => void;
|
||||
config: AiConfig;
|
||||
publicSettings: AdminPublicSettings | null;
|
||||
isPublicSettingsLoading: boolean;
|
||||
isConfigOpen: boolean;
|
||||
shouldPromptContinue: boolean;
|
||||
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
loadPublicSettings: () => Promise<void>;
|
||||
isAiConfigReady: (config: AiConfig, model: string) => boolean;
|
||||
openConfigDialog: (shouldPromptContinue?: boolean) => void;
|
||||
setConfigDialogOpen: (isOpen: boolean) => void;
|
||||
clearPromptContinue: () => void;
|
||||
};
|
||||
|
||||
function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSettings["modelChannel"] | null) {
|
||||
const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
|
||||
if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
|
||||
const models = modelChannel.availableModels;
|
||||
return {
|
||||
...config,
|
||||
channelMode,
|
||||
models,
|
||||
model: models.includes(config.model) ? config.model : modelChannel.defaultModel,
|
||||
imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel,
|
||||
textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel,
|
||||
systemPrompt: modelChannel.systemPrompt,
|
||||
};
|
||||
const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
|
||||
if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
|
||||
const models = modelChannel.availableModels;
|
||||
return {
|
||||
...config,
|
||||
channelMode,
|
||||
models,
|
||||
model: models.includes(config.model) ? config.model : modelChannel.defaultModel,
|
||||
imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel,
|
||||
textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel,
|
||||
systemPrompt: modelChannel.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function isAiConfigReady(config: AiConfig, model: string) {
|
||||
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
|
||||
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
|
||||
}
|
||||
|
||||
export const useConfigStore = create<ConfigStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
config: defaultConfig,
|
||||
publicSettings: null,
|
||||
isPublicSettingsLoading: false,
|
||||
isConfigOpen: false,
|
||||
shouldPromptContinue: false,
|
||||
updateConfig: (key, value) =>
|
||||
set((state) => ({
|
||||
config: {
|
||||
...state.config,
|
||||
[key]: value,
|
||||
},
|
||||
})),
|
||||
loadPublicSettings: async () => {
|
||||
if (get().isPublicSettingsLoading) return;
|
||||
set({ isPublicSettingsLoading: true });
|
||||
try {
|
||||
set({ publicSettings: await apiGet<AdminPublicSettings>("/api/settings") });
|
||||
} finally {
|
||||
set({ isPublicSettingsLoading: false });
|
||||
}
|
||||
},
|
||||
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
|
||||
openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }),
|
||||
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
|
||||
clearPromptContinue: () => set({ shouldPromptContinue: false }),
|
||||
}),
|
||||
{
|
||||
name: CONFIG_STORE_KEY,
|
||||
partialize: (state) => ({ config: state.config }),
|
||||
merge: (persisted, current) => {
|
||||
const config = { ...defaultConfig, ...((persisted as Partial<ConfigStore>).config || {}) };
|
||||
return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } };
|
||||
},
|
||||
},
|
||||
),
|
||||
persist(
|
||||
(set, get) => ({
|
||||
config: defaultConfig,
|
||||
publicSettings: null,
|
||||
isPublicSettingsLoading: false,
|
||||
isConfigOpen: false,
|
||||
shouldPromptContinue: false,
|
||||
updateConfig: (key, value) =>
|
||||
set((state) => ({
|
||||
config: {
|
||||
...state.config,
|
||||
[key]: value,
|
||||
},
|
||||
})),
|
||||
loadPublicSettings: async () => {
|
||||
if (get().isPublicSettingsLoading) return;
|
||||
set({ isPublicSettingsLoading: true });
|
||||
try {
|
||||
set({ publicSettings: await apiGet<AdminPublicSettings>("/api/settings") });
|
||||
} finally {
|
||||
set({ isPublicSettingsLoading: false });
|
||||
}
|
||||
},
|
||||
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
|
||||
openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }),
|
||||
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
|
||||
clearPromptContinue: () => set({ shouldPromptContinue: false }),
|
||||
}),
|
||||
{
|
||||
name: CONFIG_STORE_KEY,
|
||||
partialize: (state) => ({ config: state.config }),
|
||||
merge: (persisted, current) => {
|
||||
const config = { ...defaultConfig, ...((persisted as Partial<ConfigStore>).config || {}) };
|
||||
return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useEffectiveConfig() {
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
|
||||
return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
|
||||
return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]);
|
||||
}
|
||||
|
||||
export function buildApiUrl(baseUrl: string, path: string) {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
|
||||
return `${apiBaseUrl}${path}`;
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
|
||||
return `${apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
@@ -4,16 +4,16 @@ import { persist } from "zustand/middleware";
|
||||
export type ThemeName = "light" | "dark";
|
||||
|
||||
type ThemeStore = {
|
||||
theme: ThemeName;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
theme: ThemeName;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: "dark",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}),
|
||||
{ name: "infinite-canvas:theme_store" },
|
||||
),
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: "dark",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}),
|
||||
{ name: "infinite-canvas:theme_store" },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,73 +6,73 @@ 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>;
|
||||
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;
|
||||
},
|
||||
},
|
||||
),
|
||||
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;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type ReferenceImage = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
dataUrl: string;
|
||||
url?: string;
|
||||
storageKey?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
dataUrl: string;
|
||||
url?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
+25
-40
@@ -1,42 +1,27 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
"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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user