style(code): 格式化代码缩进和布局样式

- 统一调整 admin.ts 文件中的接口定义缩进格式
- 优化 animated-theme-toggler.tsx 组件的代码结构和缩进
- 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式
- 整理 app-providers.tsx 中的组件层级缩进
- 标准化 app-theme.ts 中的对象属性缩进格式
This commit is contained in:
HouYunFei
2026-05-22 23:19:25 +08:00
parent cee08cb5a8
commit e319481976
93 changed files with 11406 additions and 10424 deletions
+10
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
.next
node_modules
dist
build
coverage
bun.lock
*.log
*.ico
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.svg
+15
View File
@@ -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"
}
+3
View File
@@ -35,6 +35,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "19.1.12", "@types/react": "19.1.12",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.1.9",
"prettier": "^3.8.3",
"typescript": "^5", "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=="], "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=="], "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=="], "prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+4 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "next dev --webpack -H 0.0.0.0 -p 3000", "dev": "next dev --webpack -H 0.0.0.0 -p 3000",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.1", "@ant-design/icons": "^6.1.1",
@@ -39,6 +41,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "19.1.12", "@types/react": "19.1.12",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.1.9",
"prettier": "^3.8.3",
"typescript": "^5" "typescript": "^5"
} }
} }
+128 -25
View File
@@ -41,7 +41,10 @@ export default function AdminAssetsPage() {
...value, ...value,
type: nextType, type: nextType,
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""), coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean), tags: (value.tagText || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
}); });
setEditingAsset(null); setEditingAsset(null);
}; };
@@ -57,7 +60,11 @@ export default function AdminAssetsPage() {
title: "标题", title: "标题",
dataIndex: "title", dataIndex: "title",
width: 260, width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>, render: (_, item) => (
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
{item.title}
</Typography.Link>
),
}, },
{ {
title: "类型", title: "类型",
@@ -69,7 +76,13 @@ export default function AdminAssetsPage() {
title: "标签", title: "标签",
dataIndex: "tags", dataIndex: "tags",
width: 180, width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>, render: (_, item) => (
<Space size={[4, 4]} wrap>
{(item.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
),
}, },
{ {
title: "分类", title: "分类",
@@ -84,9 +97,15 @@ export default function AdminAssetsPage() {
align: "right", align: "right",
render: (_, item) => ( render: (_, item) => (
<Space size={4}> <Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip> <Tooltip title="详情">
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip> <Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip> </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> </Space>
), ),
}, },
@@ -98,10 +117,31 @@ export default function AdminAssetsPage() {
<Card variant="borderless"> <Card variant="borderless">
<Form layout="vertical"> <Form layout="vertical">
<Row gutter={16} align="bottom"> <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="360px">
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col> <Form.Item label="关键词">
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col> <Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} />
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}></Button></Space></Form.Item></Col> </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> </Row>
</Form> </Form>
</Card> </Card>
@@ -114,22 +154,59 @@ export default function AdminAssetsPage() {
defaultSize="middle" defaultSize="middle"
tableLayout="fixed" tableLayout="fixed"
cardProps={{ variant: "borderless" }} cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>} headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshAssets() }} options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}></Button>]} toolBarRender={() => [
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }} <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> </Flex>
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> <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 form={form} layout="vertical" requiredMark={false}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item> <Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item> <Select options={editTypeOptions} />
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item> </Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item> <Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Form.Item name="category" label="分类"><Input /></Form.Item> <Input />
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item> </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.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> </Form>
</Modal> </Modal>
@@ -139,18 +216,44 @@ export default function AdminAssetsPage() {
<Flex gap={14} align="start"> <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" /> <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 }}> <Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title> <Typography.Title level={5} style={{ margin: 0 }}>
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space> {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>
</Flex> </Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null} {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 /> <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> <Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
</Button>
</Flex> </Flex>
) : null} ) : null}
</Modal> </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="取消"> <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} {deletingAsset?.title}
</Modal> </Modal>
</main> </main>
+24 -6
View File
@@ -52,22 +52,40 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}> <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}` }}> <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" }} /> <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> <Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
</Typography.Text>
</Flex> </Flex>
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[activeKey]} selectedKeys={[activeKey]}
style={adminLayoutStyle.menu} style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))} 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 }}> <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={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
<Button block icon={<LogoutOutlined />} onClick={logout}>退</Button>
</Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Flex> </Flex>
</Layout.Sider> </Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}> <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}` }}> <Layout.Header
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title> 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}> <Flex align="center" gap={4}>
<UserStatusActions showConfig={false} /> <UserStatusActions showConfig={false} />
</Flex> </Flex>
+199 -30
View File
@@ -10,7 +10,30 @@ import type { Prompt } from "@/services/api/prompts";
import { useAdminPrompts } from "./use-admin-prompts"; import { useAdminPrompts } from "./use-admin-prompts";
export default function AdminPromptsPage() { 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 {
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 copyText = useCopyText();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>(); const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null); const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
@@ -30,7 +53,15 @@ export default function AdminPromptsPage() {
const savePrompt = async () => { const savePrompt = async () => {
const value = await form.validateFields(); const value = await form.validateFields();
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) }); await saveAdminPrompt({
...editingPrompt,
...value,
category: value.category || defaultCategory,
tags: (value.tagText || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
});
setEditingPrompt(null); setEditingPrompt(null);
}; };
@@ -51,7 +82,11 @@ export default function AdminPromptsPage() {
title: "标题", title: "标题",
dataIndex: "title", dataIndex: "title",
width: 260, width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>, render: (_, item) => (
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
{item.title}
</Typography.Link>
),
}, },
{ {
title: "分类", title: "分类",
@@ -63,7 +98,13 @@ export default function AdminPromptsPage() {
title: "标签", title: "标签",
dataIndex: "tags", dataIndex: "tags",
width: 180, width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>, render: (_, item) => (
<Space size={[4, 4]} wrap>
{(item.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
),
}, },
{ {
title: "操作", title: "操作",
@@ -72,9 +113,15 @@ export default function AdminPromptsPage() {
align: "right", align: "right",
render: (_, item) => ( render: (_, item) => (
<Space size={4}> <Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip> <Tooltip title="详情">
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip> <Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip> </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> </Space>
), ),
}, },
@@ -86,10 +133,31 @@ export default function AdminPromptsPage() {
<Card variant="borderless"> <Card variant="borderless">
<Form layout="vertical"> <Form layout="vertical">
<Row gutter={16} align="bottom"> <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="360px">
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col> <Form.Item label="关键词">
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col> <Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}></Button></Space></Form.Item></Col> </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> </Row>
</Form> </Form>
</Card> </Card>
@@ -102,25 +170,54 @@ export default function AdminPromptsPage() {
defaultSize="middle" defaultSize="middle"
tableLayout="fixed" tableLayout="fixed"
cardProps={{ variant: "borderless" }} cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>} headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshPrompts() }} options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }} rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
toolBarRender={() => [ toolBarRender={() => [
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}</Button>, <Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}></Button>, {selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}></Button>, </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) }} 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> </Flex>
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden> <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 form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item> <Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item> <Input />
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item> </Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item> <Form.Item name="category" label="分类">
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item> <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> </Form>
</Modal> </Modal>
@@ -130,33 +227,105 @@ export default function AdminPromptsPage() {
<Flex gap={14} align="start"> <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" /> <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 }}> <Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title> <Typography.Title level={5} style={{ margin: 0 }}>
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space> {detailPrompt.title}
</Typography.Title>
<Space wrap>
<Tag>{categoryName(detailPrompt.category)}</Tag>
{(detailPrompt.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</Flex> </Flex>
</Flex> </Flex>
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null} {detailPrompt.preview ? (
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
{detailPrompt.preview}
</Typography.Paragraph>
) : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly /> <Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space> <Space>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}></Button> <Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Button>
{detailPrompt.githubUrl ? (
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
</Button>
) : null}
</Space> </Space>
</Flex> </Flex>
) : null} ) : null}
</Modal> </Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}> <Modal
title="同步远程提示词源"
open={isSyncOpen}
width={640}
onCancel={() => !isSyncing && setIsSyncOpen(false)}
mask={{ closable: !isSyncing }}
footer={
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
</Button>
}
>
<Table <Table
rowKey="category" rowKey="category"
dataSource={categories.filter((item) => item.remote)} dataSource={categories.filter((item) => item.remote)}
pagination={false} pagination={false}
columns={[ 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> }, 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>
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <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} {deletingPrompt?.title}
</Modal> </Modal>
+160 -37
View File
@@ -268,8 +268,12 @@ export default function AdminSettingsPage() {
]} ]}
/> />
<Space> <Space>
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}></Button> <Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}></Button>
</Button>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>
</Button>
</Space> </Space>
</Flex> </Flex>
</Card> </Card>
@@ -279,12 +283,23 @@ export default function AdminSettingsPage() {
<Segmented <Segmented
value={activeMode} value={activeMode}
onChange={(value) => toggleMode(activeTab, value as EditorMode)} onChange={(value) => toggleMode(activeTab, value as EditorMode)}
options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} options={[
{ label: "可视化编辑", value: "visual" },
{ label: "手动编辑 JSON", value: "json" },
]}
/> />
{activeMode === "json" ? ( {activeMode === "json" ? (
<Space> <Space>
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON </Tag>} {jsonError ? (
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}></Button> <Tag color="error">{jsonError}</Tag>
) : (
<Tag color="success" icon={<CheckCircleOutlined />}>
JSON
</Tag>
)}
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>
</Button>
</Space> </Space>
) : ( ) : (
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text> <Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
@@ -295,12 +310,36 @@ export default function AdminSettingsPage() {
activeMode === "visual" ? ( activeMode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}> <Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Row gutter={16}> <Row gutter={16}>
<Col span={24}><Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)"><Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} /></Form.Item></Col> <Col span={24}>
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col> <Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)">
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col> <Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col> </Form.Item>
<Col span={24}><Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col> </Col>
<Col span={24}><Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked"><Switch /></Form.Item></Col> <Col xs={24} md={8}>
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词">
<Input.TextArea rows={4} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row> </Row>
</Form> </Form>
) : ( ) : (
@@ -319,18 +358,24 @@ export default function AdminSettingsPage() {
) : activeMode === "visual" ? ( ) : activeMode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}> <Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Flex vertical gap={12}> <Flex vertical gap={12}>
<Alert <Alert showIcon type="warning" title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。" />
showIcon
type="warning"
title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
/>
<Card size="small" title="提示词定时同步"> <Card size="small" title="提示词定时同步">
<Row gutter={16} align="middle"> <Row gutter={16} align="middle">
<Col xs={24} md={8}><Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked"><Switch /></Form.Item></Col> <Col xs={24} md={8}>
<Col xs={24} md={16}><Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每天 03:00 同步内置 GitHub 远程提示词源"><Input placeholder="0 3 * * *" /></Form.Item></Col> <Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={16}>
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每天 03:00 同步内置 GitHub 远程提示词源">
<Input placeholder="0 3 * * *" />
</Form.Item>
</Col>
</Row> </Row>
</Card> </Card>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>
</Button>
<Table <Table
rowKey="_rowKey" rowKey="_rowKey"
pagination={false} pagination={false}
@@ -339,7 +384,15 @@ export default function AdminSettingsPage() {
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" }, { title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> }, { title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> }, { title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
{ title: "模型", dataIndex: "models", render: (value: string[]) => <Typography.Text ellipsis style={{ maxWidth: 360 }}>{modelSummary(value || [])}</Typography.Text> }, {
title: "模型",
dataIndex: "models",
render: (value: string[]) => (
<Typography.Text ellipsis style={{ maxWidth: 360 }}>
{modelSummary(value || [])}
</Typography.Text>
),
},
{ title: "权重", dataIndex: "weight", width: 88 }, { title: "权重", dataIndex: "weight", width: 88 },
{ {
title: "操作", title: "操作",
@@ -348,13 +401,22 @@ export default function AdminSettingsPage() {
align: "right", align: "right",
render: (_, item) => ( render: (_, item) => (
<Space size={4}> <Space size={4}>
<Button size="small" onClick={() => openTestDialog(item._index)}></Button> <Button size="small" onClick={() => openTestDialog(item._index)}>
<Button size="small" onClick={() => openChannelDrawer(item._index)}></Button>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => { </Button>
<Button size="small" onClick={() => openChannelDrawer(item._index)}>
</Button>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => {
const nextChannels = [...channels]; const nextChannels = [...channels];
nextChannels.splice(item._index, 1); nextChannels.splice(item._index, 1);
void persistChannels(nextChannels); void persistChannels(nextChannels);
}} /> }}
/>
</Space> </Space>
), ),
}, },
@@ -376,33 +438,90 @@ export default function AdminSettingsPage() {
</div> </div>
)} )}
</Card> </Card>
<Drawer title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"} open={isChannelDrawerOpen} size={560} onClose={closeChannelDrawer} extra={<Space><Button onClick={closeChannelDrawer}></Button><Button type="primary" onClick={() => void saveChannel()}></Button></Space>} destroyOnHidden> <Drawer
title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"}
open={isChannelDrawerOpen}
size={560}
onClose={closeChannelDrawer}
extra={
<Space>
<Button onClick={closeChannelDrawer}></Button>
<Button type="primary" onClick={() => void saveChannel()}>
</Button>
</Space>
}
destroyOnHidden
>
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}> <Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}><Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}><Input /></Form.Item></Col> <Col span={12}>
<Col span={12}><Form.Item name="protocol" label="协议"><Select options={[{ label: "OpenAI", value: "openai" }]} /></Form.Item></Col> <Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}>
<Col span={12}><Form.Item name="weight" label="权重"><InputNumber min={1} step={1} className="!w-full" /></Form.Item></Col> <Input />
<Col span={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col> </Form.Item>
<Col span={24}><Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}><Input /></Form.Item></Col> </Col>
<Col span={24}><Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}><Input.Password /></Form.Item></Col> <Col span={12}>
<Form.Item name="protocol" label="协议">
<Select options={[{ label: "OpenAI", value: "openai" }]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="weight" label="权重">
<InputNumber min={1} step={1} className="!w-full" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}>
<Input />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}>
<Input.Password />
</Form.Item>
</Col>
<Col span={24}> <Col span={24}>
<Form.Item label="渠道可用模型"> <Form.Item label="渠道可用模型">
<Space.Compact style={{ width: "100%" }}> <Space.Compact style={{ width: "100%" }}>
<Form.Item name="models" noStyle><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item> <Form.Item name="models" noStyle>
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}></Button> <Select mode="tags" tokenSeparators={[",", "\n"]} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
</Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={24}><Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item></Col> <Col span={24}>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
</Row> </Row>
</Form> </Form>
</Drawer> </Drawer>
<Modal <Modal
title={<Space>{testChannel?.name || "渠道"} <Typography.Text type="secondary"> {testChannel?.models.length || 0} </Typography.Text></Space>} title={
<Space>
{testChannel?.name || "渠道"} <Typography.Text type="secondary"> {testChannel?.models.length || 0} </Typography.Text>
</Space>
}
open={testChannelIndex !== null} open={testChannelIndex !== null}
width={920} width={920}
onCancel={closeTestDialog} onCancel={closeTestDialog}
footer={<Space><Button onClick={closeTestDialog}></Button><Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}> {selectedTestModels.length} </Button></Space>} footer={
<Space>
<Button onClick={closeTestDialog}></Button>
<Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>
{selectedTestModels.length}
</Button>
</Space>
}
destroyOnHidden destroyOnHidden
> >
<Flex vertical gap={12}> <Flex vertical gap={12}>
@@ -442,7 +561,11 @@ export default function AdminSettingsPage() {
key: "actions", key: "actions",
width: 120, width: 120,
align: "right", align: "right",
render: (_, item) => <Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}></Button>, render: (_, item) => (
<Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>
</Button>
),
}, },
]} ]}
/> />
+90 -16
View File
@@ -42,7 +42,7 @@ export default function AssetLibraryPage() {
const total = query.data?.total || 0; const total = query.data?.total || 0;
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]); setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
}; };
const saveToMyAssets = async (asset: AssetLibraryItem) => { const saveToMyAssets = async (asset: AssetLibraryItem) => {
@@ -95,7 +95,17 @@ export default function AssetLibraryPage() {
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p> <p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div> </div>
<div className="mx-auto mt-8 w-full max-w-2xl"> <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); }} /> <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>
<div className="mx-auto mt-6 max-w-6xl space-y-3"> <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="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
@@ -106,7 +116,15 @@ export default function AssetLibraryPage() {
{ label: "文本", value: "text" }, { label: "文本", value: "text" },
{ label: "图片", value: "image" }, { label: "图片", value: "image" },
].map((item) => ( ].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); }}> <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} {item.label}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
@@ -115,11 +133,26 @@ export default function AssetLibraryPage() {
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"> <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="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2"> <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
checked={selectedTags.length === 0}
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
onChange={() => {
setPage(1);
setSelectedTags([]);
}}
>
</Tag.CheckableTag> </Tag.CheckableTag>
{availableTags.map((tag) => ( {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.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
onChange={() => {
setPage(1);
toggleTag(tag);
}}
>
{tag} {tag}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
@@ -146,23 +179,43 @@ export default function AssetLibraryPage() {
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}> <Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? ( {selectedAsset ? (
<div className="space-y-5"> <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>} {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> <div>
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title> <Typography.Title level={4} className="!mb-2">
{selectedAsset.title}
</Typography.Title>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag> <Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)} {selectedAsset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div> </div>
</div> </div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800"> <div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text> <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>} {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> </div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null} {selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2"> <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 === "text" ? (
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}></Button> : null} <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</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>
</div> </div>
) : null} ) : null}
@@ -174,7 +227,20 @@ export default function AssetLibraryPage() {
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) { function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
const cover = asset.coverUrl; const cover = asset.coverUrl;
return ( 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>}> <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}> <button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -185,14 +251,22 @@ function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen
{asset.type === "text" ? asset.content : asset.url} {asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph> </Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5"> <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.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} {!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div> </div>
</div> </div>
</button> </button>
<div className="flex items-center gap-2 px-4 pb-4"> <div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button> <Button size="small" onClick={onOpen}>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}></Button>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
</Button>
</div> </div>
</Card> </Card>
); );
+156 -46
View File
@@ -169,7 +169,22 @@ export default function AssetsPage() {
</div> </div>
<div className="mx-auto mt-8 w-full max-w-2xl"> <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); }} /> <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>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left"> <div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
@@ -178,13 +193,25 @@ export default function AssetsPage() {
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{kindOptions.map((option) => ( {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"); }}> <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} {option.label}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
</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
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> </button>
</div> </div>
@@ -194,15 +221,7 @@ export default function AssetsPage() {
<div className="mx-auto flex max-w-7xl flex-col gap-5"> <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"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleAssets.map((asset) => ( {visibleAssets.map((asset) => (
<AssetCard <AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
key={asset.id}
asset={asset}
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyAssetText}
onDownload={downloadImage}
onDelete={() => setDeletingAsset(asset)}
/>
))} ))}
</div> </div>
@@ -228,7 +247,13 @@ export default function AssetsPage() {
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]"> <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 form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型"> <Form.Item name="kind" label="类型">
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} /> <Select
options={[
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
]}
onChange={(value) => setFormKind(value)}
/>
</Form.Item> </Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}> <Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" /> <Input size="large" placeholder="给素材起一个容易检索的名字" />
@@ -236,7 +261,9 @@ export default function AssetsPage() {
<Form.Item name="coverUrl" label="封面 URL"> <Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full"> <Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" /> <Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}></Button> <Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
</Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item name="tags" label="标签"> <Form.Item name="tags" label="标签">
@@ -257,8 +284,18 @@ export default function AssetsPage() {
) : ( ) : (
<Form.Item label="图片内容" required> <Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700"> <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> <Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
{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>}
</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> </div>
</Form.Item> </Form.Item>
)} )}
@@ -266,24 +303,50 @@ export default function AssetsPage() {
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950"> <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> <Typography.Text strong></Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <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>} {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"> <div className="p-4">
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text> <Typography.Text strong ellipsis className="block">
{title || "未命名素材"}
</Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5"> <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>} {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>
</div> </div>
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => { <input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readCoverFile(event.target.files?.[0]); void readCoverFile(event.target.files?.[0]);
event.target.value = ""; event.target.value = "";
}} /> }}
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => { />
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readImageFile(event.target.files?.[0]); void readImageFile(event.target.files?.[0]);
event.target.value = ""; event.target.value = "";
}} /> }}
/>
</Modal> </Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> <AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
@@ -305,7 +368,11 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
cover={ cover={
<button type="button" className="block w-full text-left" onClick={onOpen}> <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>} {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>
} }
> >
@@ -314,7 +381,9 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2> <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> <Typography.Text type="secondary" className="mt-1 block text-xs">
{asset.source || "未标注来源"}
</Typography.Text>
</div> </div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag> <Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
</div> </div>
@@ -322,17 +391,35 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
{summary} {summary}
</Typography.Paragraph> </Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5"> <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 || []).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} {!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div> </div>
</div> </div>
</button> </button>
<div className="flex items-center gap-2 px-4 pb-4"> <div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button> <Button size="small" onClick={onOpen}>
<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} </Button>
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}></Button> : null} <Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}></Button>
</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> </div>
</Card> </Card>
); );
@@ -344,22 +431,51 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}> <Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? ( {asset ? (
<div className="space-y-5"> <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>} {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> <div>
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title> <Typography.Title level={4} className="!mb-2">
{asset.title}
</Typography.Title>
<Space size={[4, 4]} wrap> <Space size={[4, 4]} wrap>
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag> <Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)} {(asset.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space> </Space>
</div> </div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800"> <div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text> <Typography.Text type="secondary" className="block text-xs">
{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>}
</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> </div>
{asset.note ? <div><Typography.Text type="secondary"></Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null} {asset.note ? (
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
</div>
) : null}
<Space> <Space>
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}></Button> : null} {asset.kind === "text" ? (
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}></Button> : null} <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> </Space>
</div> </div>
) : null} ) : null}
@@ -373,11 +489,5 @@ function assetSummary(asset: Asset) {
} }
function assetSearchText(asset: Asset) { function assetSearchText(asset: Asset) {
return [ return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
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
@@ -12,9 +12,7 @@ import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"
export type AssetPickerTab = "my-assets" | "library"; export type AssetPickerTab = "my-assets" | "library";
export type InsertAssetPayload = export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string };
| { kind: "text"; content: string; title: string }
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
type Props = { type Props = {
open: boolean; open: boolean;
@@ -31,15 +29,7 @@ export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onC
}, [open, defaultTab]); }, [open, defaultTab]);
return ( return (
<Modal <Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
title="选择素材"
open={open}
onCancel={onClose}
footer={null}
width={860}
destroyOnHidden
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
>
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={(key) => setActiveTab(key as AssetPickerTab)} onChange={(key) => setActiveTab(key as AssetPickerTab)}
@@ -102,15 +92,25 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
placeholder="搜索素材" placeholder="搜索素材"
value={keyword} value={keyword}
allowClear allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }} onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/> />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => ( {[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((opt) => (
<Tag.CheckableTag <Tag.CheckableTag
key={opt.value || "all"} key={opt.value || "all"}
checked={kindFilter === opt.value} checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")} className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }} onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
> >
{opt.label} {opt.label}
</Tag.CheckableTag> </Tag.CheckableTag>
@@ -119,18 +119,13 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
</div> </div>
{query.isLoading ? ( {query.isLoading ? (
<div className="flex justify-center py-16"><Spin /></div> <div className="flex justify-center py-16">
<Spin />
</div>
) : items.length ? ( ) : items.length ? (
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
{items.map((asset) => ( {items.map((asset) => (
<PickerCard <PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
key={asset.id}
title={asset.title}
kind={asset.type}
cover={asset.coverUrl}
loading={inserting === asset.id}
onClick={() => void handleInsert(asset)}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -157,9 +152,7 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
{cover ? ( {cover ? (
<img src={cover} alt={title} className="aspect-[4/3] w-full object-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"> <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>
{title}
</div>
)} )}
<div className="p-2.5"> <div className="p-2.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -172,9 +165,7 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
<Spin size="small" /> <Spin size="small" />
</div> </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 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>
</div>
</button> </button>
); );
} }
@@ -229,7 +220,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
placeholder="搜索素材" placeholder="搜索素材"
value={keyword} value={keyword}
allowClear allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }} onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/> />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{kindOptions.map((opt) => ( {kindOptions.map((opt) => (
@@ -237,7 +231,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
key={opt.value} key={opt.value}
checked={kindFilter === opt.value} checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")} className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }} onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
> >
{opt.label} {opt.label}
</Tag.CheckableTag> </Tag.CheckableTag>
@@ -57,7 +57,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const [resizing, setResizing] = useState(false); const [resizing, setResizing] = useState(false);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set()); const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => sessions.length ? sessions : [createSession()]); const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId); const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
useEffect(() => { useEffect(() => {
@@ -85,7 +85,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
}, [selectedNodeKey]); }, [selectedNodeKey]);
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => { const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session)); setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session)));
}; };
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => { const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
@@ -100,7 +100,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => { const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
updateSession(sessionId, (session) => ({ updateSession(sessionId, (session) => ({
...session, ...session,
messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message), messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
})); }));
}; };
@@ -160,7 +160,9 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
try { try {
if (nextMode === "image") { if (nextMode === "image") {
const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey }))); const referenceImages: ReferenceImage[] = await Promise.all(
refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })),
);
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text); const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl))); const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
updateMessage(session.id, assistantId, { updateMessage(session.id, assistantId, {
@@ -225,10 +227,19 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }} style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
> >
<motion.aside className="relative flex shrink-0 flex-col border-l" initial={{ x: 48 }} animate={{ x: closing ? 28 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}> <motion.aside
className="relative flex shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: closing ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" /> <button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}> <div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center gap-2 text-sm font-medium"><Sparkles className="size-4" />{view === "history" ? "历史记录" : "画布助手(未开发)"}</div> <div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4" />
{view === "history" ? "历史记录" : "画布助手(未开发)"}
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{view === "history" ? ( {view === "history" ? (
<> <>
@@ -236,7 +247,15 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
</Tooltip> </Tooltip>
<Tooltip title="删除全部"> <Tooltip title="删除全部">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} /> <Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
style={iconButtonStyle}
icon={<X className="size-4" />}
disabled={!historySessions.length}
onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))}
/>
</Tooltip> </Tooltip>
</> </>
) : null} ) : null}
@@ -244,7 +263,18 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
</Tooltip> </Tooltip>
<Tooltip title="新对话"> <Tooltip title="新对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Plus className="size-4" />} disabled={!hasMessages} onClick={() => { startChatSession(); setView("chat"); }} /> <Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
style={iconButtonStyle}
icon={<Plus className="size-4" />}
disabled={!hasMessages}
onClick={() => {
startChatSession();
setView("chat");
}}
/>
</Tooltip> </Tooltip>
<Tooltip title="配置"> <Tooltip title="配置">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} /> <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
@@ -261,8 +291,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
sessions={historySessions} sessions={historySessions}
activeSession={activeSession} activeSession={activeSession}
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))} checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))} onToggleChecked={(id, checked) => setCheckedChatIds((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id)))}
onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }} onOpen={(id) => {
setLocalActiveSessionId(id);
setView("chat");
}}
onDelete={(id) => setDeleteChatIds([id])} onDelete={(id) => setDeleteChatIds([id])}
/> />
) : messages.length ? ( ) : messages.length ? (
@@ -271,14 +304,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
<div className="flex h-full flex-col items-center justify-center px-1 text-center"> <div className="flex h-full flex-col items-center justify-center px-1 text-center">
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}> <div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
<span>Infinite Canvas</span> <span>Infinite Canvas</span>
<DiaTextReveal <DiaTextReveal className="absolute inset-0" colors={["#A97CF8", "#F38CB8", "#FDCC92"]} textColor="transparent" duration={1.8} startOnView={false} text="Infinite Canvas" />
className="absolute inset-0"
colors={["#A97CF8", "#F38CB8", "#FDCC92"]}
textColor="transparent"
duration={1.8}
startOnView={false}
text="Infinite Canvas"
/>
</div> </div>
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div> <div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
</div> </div>
@@ -310,7 +336,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
open={deleteChatIds.length > 0} open={deleteChatIds.length > 0}
centered centered
onCancel={() => setDeleteChatIds([])} onCancel={() => setDeleteChatIds([])}
footer={<> footer={
<>
<Button onClick={() => setDeleteChatIds([])}></Button> <Button onClick={() => setDeleteChatIds([])}></Button>
<Button <Button
danger danger
@@ -322,7 +349,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
> >
</Button> </Button>
</>} </>
}
> >
<p className="text-sm opacity-60"> {deleteChatIds.length} </p> <p className="text-sm opacity-60"> {deleteChatIds.length} </p>
</Modal> </Modal>
@@ -364,7 +392,9 @@ function AssistantComposer({
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}> <div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
{references.length ? ( {references.length ? (
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1"> <div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
{references.map((item) => <AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />)} {references.map((item) => (
<AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />
))}
</div> </div>
) : null} ) : null}
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}> <div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
@@ -396,8 +426,22 @@ function AssistantComposer({
value={mode} value={mode}
onChange={(value) => onModeChange(value as AssistantMode)} onChange={(value) => onModeChange(value as AssistantMode)}
options={[ options={[
{ value: "ask", label: <Tooltip title="文本"><MessageSquare className="size-4" /></Tooltip> }, {
{ value: "image", label: <Tooltip title="生图"><ImageIcon className="size-4" /></Tooltip> }, value: "ask",
label: (
<Tooltip title="文本">
<MessageSquare className="size-4" />
</Tooltip>
),
},
{
value: "image",
label: (
<Tooltip title="生图">
<ImageIcon className="size-4" />
</Tooltip>
),
},
]} ]}
/> />
</CanvasThemeProvider> </CanvasThemeProvider>
@@ -410,7 +454,15 @@ function AssistantComposer({
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} /> <ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
)} )}
</div> </div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10 shrink-0" icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} disabled={isRunning || !prompt.trim()} onClick={() => void onSubmit()} aria-label="发送" /> <Button
type="primary"
shape="circle"
className="!h-10 !w-10 !min-w-10 shrink-0"
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
disabled={isRunning || !prompt.trim()}
onClick={() => void onSubmit()}
aria-label="发送"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -419,14 +471,28 @@ function AssistantComposer({
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) { function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
return ( return (
<ConfigProvider theme={{ token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel }, components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text }, Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 } } }}> <ConfigProvider
theme={{
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
components: {
Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text },
Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 },
},
}}
>
{children} {children}
</ConfigProvider> </ConfigProvider>
); );
} }
function SettingTitle({ children, color }: { children: string; color: string }) { 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) { function qualityLabel(value: string) {
@@ -450,8 +516,16 @@ function AssistantMessages({
<> <>
{messages.map((message) => ( {messages.map((message) => (
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}> <div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
<div className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6" style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}> <div
{message.role === "assistant" ? <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"><MessageSquare className="size-3.5" /></div> : null} className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6"
style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}
>
{message.role === "assistant" ? (
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
<MessageSquare className="size-3.5" />
</div>
) : null}
{message.text} {message.text}
</div> </div>
{message.references?.length ? <MessageReferences message={message} /> : null} {message.references?.length ? <MessageReferences message={message} /> : null}
@@ -465,7 +539,14 @@ function AssistantMessages({
{message.images?.map((image) => ( {message.images?.map((image) => (
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}> <div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" /> <img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
<Button type="text" className="!h-8 !w-full !rounded-none" style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }} icon={<Plus className="size-3.5" />} onClick={() => onInsertImage(image)} title="插入画布" /> <Button
type="text"
className="!h-8 !w-full !rounded-none"
style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
icon={<Plus className="size-3.5" />}
onClick={() => onInsertImage(image)}
title="插入画布"
/>
</div> </div>
))} ))}
</div> </div>
@@ -510,7 +591,9 @@ function AssistantHistory({
function MessageReferences({ message }: { message: CanvasAssistantMessage }) { function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
return ( return (
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}> <div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
{message.references?.map((item) => <AssistantReferenceChip key={item.id} item={item} />)} {message.references?.map((item) => (
<AssistantReferenceChip key={item.id} item={item} />
))}
</div> </div>
); );
} }
@@ -520,9 +603,21 @@ function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantRefer
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文"; const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
return ( return (
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}> <div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
{item.dataUrl ? <img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" /> : <span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>{text}</span>} {item.dataUrl ? (
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
) : (
<span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>
{text}
</span>
)}
{onRemove ? ( {onRemove ? (
<button type="button" className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }} onClick={onRemove} aria-label="移除引用"> <button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" /> <X className="size-3" />
</button> </button>
) : null} ) : null}
@@ -550,19 +645,21 @@ function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<
} }
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> { async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
return Promise.all(messages.map(async (message, index) => { return Promise.all(
messages.map(async (message, index) => {
if (message.role === "assistant") return { role: "assistant", content: message.text }; if (message.role === "assistant") return { role: "assistant", content: message.text };
if (index !== messages.length - 1) return { role: "user", content: message.text }; if (index !== messages.length - 1) return { role: "user", content: message.text };
const refs = message.references || []; const refs = message.references || [];
return { return {
role: "user", role: "user",
content: [ content: [
...refs.flatMap((item) => item.text ? [{ type: "text" as const, text: item.text }] : []), ...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: item.text }] : [])),
{ type: "text", text: message.text }, { type: "text", text: message.text },
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))), ...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
], ],
}; };
})); }),
);
} }
function createSession(): CanvasAssistantSession { function createSession(): CanvasAssistantSession {
@@ -73,8 +73,24 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
value={mode} value={mode}
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })} onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
options={[ 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> }, 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>
@@ -83,12 +99,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}> <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.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} /> <InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<button <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)}>
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" /> <Eye className="size-3.5" />
</button> </button>
@@ -132,7 +143,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<div className="shrink-0"> <div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词"> <PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1"> <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} />)} {imageInputs.map((input, index) => (
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
))}
</div> </div>
</PreviewSection> </PreviewSection>
</div> </div>
@@ -140,7 +153,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5"> <div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词"> <PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5"> <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} />)} {textInputs.map((input, index) => (
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
))}
</div> </div>
</PreviewSection> </PreviewSection>
</div> </div>
@@ -149,12 +164,18 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<> <>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div> <div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}></Button> <Button size="small" type="text" onClick={() => setEditingTextId(null)}>
</Button>
</div> </div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} /> <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"> <div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}></Button> <Button size="small" onClick={() => setEditingTextId(null)}>
<Button size="small" type="primary" onClick={saveTextEdit}></Button>
</Button>
<Button size="small" type="primary" onClick={saveTextEdit}>
</Button>
</div> </div>
</> </>
) : ( ) : (
@@ -187,7 +208,23 @@ function PreviewSection({ title, count, empty, children }: { title: string; coun
); );
} }
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 }) { 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 ( 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="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="min-w-0">
@@ -202,7 +239,21 @@ function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEd
); );
} }
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 }) { 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; if (!input.image) return null;
return ( return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}> <div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
@@ -2,19 +2,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types"; import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
export function ConnectionPath({ export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
connection,
from,
to,
active,
onSelect,
}: {
connection: CanvasConnection;
from: CanvasNodeData;
to: CanvasNodeData;
active: boolean;
onSelect: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startX = from.position.x + from.width; const startX = from.position.x + from.width;
const startY = from.position.y + from.height / 2; const startY = from.position.y + from.height / 2;
@@ -50,15 +38,7 @@ export function ConnectionPath({
); );
} }
export function ActiveConnectionPath({ export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
node,
handle,
mouseWorld,
}: {
node?: CanvasNodeData;
handle: ConnectionHandle;
mouseWorld: Position;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!node) return null; if (!node) return null;
@@ -8,17 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "../types"; import type { ContextMenuState } from "../types";
export function CanvasNodeContextMenu({ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
menu,
onClose,
onDuplicate,
onDelete,
}: {
menu: ContextMenuState;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => { useEffect(() => {
@@ -43,26 +33,11 @@ export function CanvasNodeContextMenu({
); );
} }
function MenuButton({ function MenuButton({ icon, label, onClick, danger = false }: { icon: ReactNode; label: string; onClick?: () => void; danger?: boolean }) {
icon,
label,
onClick,
danger = false,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
return ( return (
<button <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}>
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} {icon}
<span>{label}</span> <span>{label}</span>
</button> </button>
@@ -25,7 +25,14 @@ export function CanvasDeleteProjectsDialog() {
open={ids.length > 0} open={ids.length > 0}
centered centered
onCancel={() => setDeleteIds([])} onCancel={() => setDeleteIds([])}
footer={<><Button onClick={() => setDeleteIds([])}></Button><Button danger type="primary" onClick={confirm}></Button></>} footer={
<>
<Button onClick={() => setDeleteIds([])}></Button>
<Button danger type="primary" onClick={confirm}>
</Button>
</>
}
> >
<p className="text-sm text-stone-500"> {ids.length} 线</p> <p className="text-sm text-stone-500"> {ids.length} 线</p>
</Modal> </Modal>
@@ -8,7 +8,12 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-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 = [ const aspectOptions = [
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" }, { 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: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
@@ -59,14 +64,18 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
color={theme.toolbar.panel} color={theme.toolbar.panel}
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)} getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
content={( content={
<CanvasImageSettingsTheme theme={theme}> <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="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="text-xl font-semibold"></div>
<div className="space-y-3"> <div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3"> <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>)} {qualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -81,7 +90,14 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
{aspectOptions.map((item) => ( {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)}> <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} /> <AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
@@ -91,16 +107,22 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
<div className="space-y-3"> <div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle> <SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3"> <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>)} {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))} /> <CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
</div> </div>
</div> </div>
</div> </div>
</CanvasImageSettingsTheme> </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" />}> <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> <span className="truncate">
{qualityLabel(quality)} · {selectedAspect?.label || activeSize} · {count}
</span>
</Button> </Button>
</Popover> </Popover>
); );
@@ -108,7 +130,12 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) { export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
return ( 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 } } }}> <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} {children}
</ConfigProvider> </ConfigProvider>
); );
@@ -131,8 +158,18 @@ function OptionPill({ selected, theme, onClick, children }: { selected: boolean;
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) { 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 ( 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 }}> <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> <span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
<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()} /> {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> </label>
); );
} }
@@ -140,7 +177,16 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix:
function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) { function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
return ( 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 }}> <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()} /> <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> </label>
); );
} }
@@ -158,7 +204,11 @@ function AspectIcon({ type, width, height, color }: { type: string; width: numbe
} }
function SettingTitle({ children, color }: { children: string; color: string }) { 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) { function qualityLabel(value: string) {
@@ -6,17 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types"; import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
export function Minimap({ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
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 theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -107,10 +97,7 @@ export function Minimap({
}; };
return ( return (
<div <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 }}>
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 <div
ref={containerRef} ref={containerRef}
className="relative h-full w-full cursor-crosshair" className="relative h-full w-full cursor-crosshair"
@@ -144,10 +131,7 @@ export function Minimap({
/> />
); );
})} })}
<div <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` }} />
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>
</div> </div>
); );
@@ -18,12 +18,7 @@ const defaultParams: CanvasImageAngleParams = {
wideAngle: false, wideAngle: false,
}; };
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (params: CanvasImageAngleParams) => void;
}) {
const [params, setParams] = useState(defaultParams); const [params, setParams] = useState(defaultParams);
useEffect(() => { useEffect(() => {
@@ -47,7 +42,9 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
<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 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>
</div> </div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}></Button> <Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
</Button>
</div> </div>
<div className="space-y-6 py-2"> <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.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
@@ -55,32 +52,37 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", 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"> <div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span> <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")} /> <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>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI </Button> <Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
AI
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>
); );
} }
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: { 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 }) {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return ( return (
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4"> <div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">{label}</span> <span className="font-medium opacity-75">{label}</span>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} /> <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> <span className="whitespace-nowrap text-right font-semibold">
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix}
</span>
</div> </div>
); );
} }
@@ -20,17 +20,7 @@ const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06; const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 }; const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
export function CanvasNodeCropDialog({ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
dataUrl,
open,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (crop: CanvasImageCropRect) => void;
}) {
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop); const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
@@ -88,7 +78,11 @@ export function CanvasNodeCropDialog({
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80"> <div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span> <span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span> <span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? <span> {image.width} x {image.height}</span> : null} {image ? (
<span>
{image.width} x {image.height}
</span>
) : null}
</div> </div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}> <Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"} {locked ? "锁定比例" : "自由比例"}
@@ -97,8 +91,12 @@ export function CanvasNodeCropDialog({
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button> <Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}></Button> <Button icon={<X className="size-4" />} onClick={onClose}>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}></Button>
</Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -17,14 +17,12 @@ export type NodeGenerationInput = {
image?: ReferenceImage; image?: ReferenceImage;
}; };
export function buildNodeGenerationContext( export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
prompt: string,
): NodeGenerationContext {
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections); const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n"); 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)); const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
return { return {
@@ -35,11 +33,7 @@ export function buildNodeGenerationContext(
}; };
} }
export function buildNodeGenerationInputs( export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
): NodeGenerationInput[] {
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => { return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node); const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }]; if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
@@ -57,10 +51,7 @@ export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompl
return [ return [
{ {
role: "user", role: "user",
content: [ content: [{ type: "text" as const, text: context.prompt }, ...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } }))],
{ type: "text" as const, text: context.prompt },
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
],
}, },
]; ];
} }
@@ -93,8 +84,5 @@ function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connec
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId)) .map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node)); .filter((node): node is CanvasNodeData => Boolean(node));
const order = target?.metadata?.inputOrder || []; const order = target?.metadata?.inputOrder || [];
return [ return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
...upstreamNodes.filter((node) => !order.includes(node.id)),
];
} }
@@ -84,7 +84,15 @@ export function CanvasNodeHoverToolbar({
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(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} {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} {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={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={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null} {hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
</div> </div>
@@ -98,13 +106,17 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0; const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
const json = useMemo(() => { const json = useMemo(() => {
if (!node) return ""; if (!node) return "";
return JSON.stringify(node, (key, value) => { return JSON.stringify(
node,
(key, value) => {
if (key === "title") return undefined; if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) { if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]"; return "[base64 image]";
} }
return value; return value;
}, 2); },
2,
);
}, [node]); }, [node]);
useEffect(() => { useEffect(() => {
@@ -140,7 +152,11 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null} {batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null} {node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : 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} {node.metadata?.errorDetails ? (
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
</div>
) : null}
</div> </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 }}> <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 }}>
@@ -68,7 +68,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}} }}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none" 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 }} style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={mode === "image" ? hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容" : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"} placeholder={mode === "image" ? (hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容") : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
/> />
<div className="mt-2 flex min-w-0 items-center justify-between gap-2"> <div className="mt-2 flex min-w-0 items-center justify-between gap-2">
@@ -264,18 +264,16 @@ export const CanvasNode = React.memo(function CanvasNode({
> >
<div <div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`} className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={{ style={
{
background: hasImageContent ? "transparent" : theme.node.fill, background: hasImageContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`, "--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`, "--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`, "--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
animation: data.metadata?.batchRootId animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined,
? batchClosing
? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both"
: "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both"
: undefined,
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined, animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
} as React.CSSProperties} } as React.CSSProperties
}
> >
<NodeContent <NodeContent
node={data} node={data}
@@ -297,12 +295,7 @@ export const CanvasNode = React.memo(function CanvasNode({
/> />
</div> </div>
{!hasImageContent ? ( {!hasImageContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }}
/>
) : null}
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} /> <ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} /> <ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
@@ -310,16 +303,8 @@ export const CanvasNode = React.memo(function CanvasNode({
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} /> <ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</div> </div>
<ConnectionHandleDot <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
side="left" <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
visible={hovered || isSelected || isConnecting}
onMouseDown={(event) => onConnectStart(event, data.id, "target")}
/>
<ConnectionHandleDot
side="right"
visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)}
onMouseDown={(event) => onConnectStart(event, data.id, "source")}
/>
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null} {showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div> </div>
@@ -351,11 +336,7 @@ function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
); );
} }
function ErrorContent({ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
node,
theme,
onRetry,
}: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
return ( return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center"> <div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div> <div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
@@ -376,15 +357,7 @@ function ErrorContent({
); );
} }
function TextContent({ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
node,
theme,
isEditingContent,
textareaRef,
onContentChange,
onStopEditing,
onGenerateImage,
}: NodeContentRendererProps) {
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden pt-8"> <div className="flex h-full w-full flex-col overflow-hidden pt-8">
<button <button
@@ -433,12 +406,19 @@ function TextContent({
function ImageNodeContent(props: NodeContentRendererProps) { function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) { if (!props.node.metadata?.content && props.isBatchRoot) {
const content = props.node.metadata?.status === "loading" const content =
? <LoadingContent theme={props.theme} /> props.node.metadata?.status === "loading" ? (
: props.node.metadata?.status === "error" <LoadingContent theme={props.theme} />
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} /> ) : props.node.metadata?.status === "error" ? (
: <EmptyImageContent {...props} isBatchRoot={false} />; <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>; ) : (
<EmptyImageContent {...props} isBatchRoot={false} />
);
return (
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>
{content}
</BatchFrame>
);
} }
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />; if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
@@ -465,10 +445,13 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
<span className="text-[10px] tracking-[0.18em] opacity-50"></span> <span className="text-[10px] tracking-[0.18em] opacity-50"></span>
</div> </div>
); );
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>{content}</BatchFrame>; if (isBatchRoot)
return ( return (
content <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
{content}
</BatchFrame>
); );
return content;
} }
function ImageContent({ function ImageContent({
@@ -545,7 +528,17 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchRoot = batchCount > 1; const isBatchRoot = batchCount > 1;
return ( return (
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}> <div
className="group/batch relative h-full w-full overflow-visible"
onDoubleClick={
isBatchRoot
? (event) => {
event.stopPropagation();
onToggleBatch?.();
}
: undefined
}
>
{isBatchRoot ? ( {isBatchRoot ? (
<div className="pointer-events-none absolute inset-0 overflow-visible"> <div className="pointer-events-none absolute inset-0 overflow-visible">
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => ( {Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
@@ -557,7 +550,8 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`, background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
borderColor: theme.node.stroke, borderColor: theme.node.stroke,
opacity: batchExpanded && !batchOpening ? 0.34 : 1, opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`, transform:
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
zIndex: -index - 1, zIndex: -index - 1,
}} }}
/> />
@@ -568,13 +562,7 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
</div> </div>
); );
} }
function ResizeHandle({ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
corner,
onMouseDown,
}: {
corner: ResizeCorner;
onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void;
}) {
const positionClass = { const positionClass = {
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize", "top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize", "top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
@@ -582,23 +570,10 @@ function ResizeHandle({
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize", "bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
}[corner]; }[corner];
return ( return <div className={`absolute z-50 size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
<div
className={`absolute z-50 size-7 ${positionClass}`}
onMouseDown={(event) => onMouseDown(event, corner)}
/>
);
} }
function ConnectionHandleDot({ function ConnectionHandleDot({ side, visible, onMouseDown }: { side: "left" | "right"; visible: boolean; onMouseDown: (event: React.MouseEvent) => void }) {
side,
visible,
onMouseDown,
}: {
side: "left" | "right";
visible: boolean;
onMouseDown: (event: React.MouseEvent) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
return ( return (
@@ -608,10 +583,7 @@ function ConnectionHandleDot({
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`} } ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
> >
<div <div className="size-3 rounded-full border-2 transition-all hover:scale-125" style={{ background: theme.node.panel, borderColor: theme.node.muted }} />
className="size-3 rounded-full border-2 transition-all hover:scale-125"
style={{ background: theme.node.panel, borderColor: theme.node.muted }}
/>
</div> </div>
); );
} }
@@ -40,9 +40,18 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
{editing ? ( {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 /> <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(); }}> <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> <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> <p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
{project.nodes.length} · {project.connections.length} 线
</p>
</button> </button>
)} )}
</div> </div>
@@ -15,7 +15,14 @@ export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) =
return ( return (
<> <>
<Tooltip title="提示词库"> <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="提示词库" /> <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> </Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} /> <PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
</> </>
@@ -47,7 +47,11 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
placeholder="比例" placeholder="比例"
options={options} options={options}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>} popupRender={(menu) => (
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onOpenChange={setOpen} onOpenChange={setOpen}
onSearch={setSearch} onSearch={setSearch}
onChange={selectSize} onChange={selectSize}
@@ -58,11 +58,7 @@ export function CanvasToolbar({
return ( return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}> <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} {tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div <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}>
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}> <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" /> <Hand className="size-4.5" />
</ToolbarButton> </ToolbarButton>
@@ -146,14 +142,35 @@ export function CanvasToolbar({
value={backgroundMode} value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)} onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[ 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: "dots",
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" /></span> }, 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> </div>
) : null} ) : null}
</div> </div>
); );
} }
@@ -197,7 +214,10 @@ function ToolbarButton({
disabled={disabled} disabled={disabled}
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }} style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
icon={children} icon={children}
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }} onMouseEnter={(event) => {
onHover(id);
onTipX(getTipX(wrapRef.current, event.currentTarget));
}}
onMouseLeave={() => onHover(null)} onMouseLeave={() => onHover(null)}
onClick={onClick} onClick={onClick}
/> />
@@ -208,17 +228,7 @@ 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({ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
colorTheme,
targetTheme,
onThemeChange,
children,
}: {
colorTheme: CanvasColorTheme;
targetTheme: CanvasColorTheme;
onThemeChange: (theme: CanvasColorTheme) => void;
children: ReactNode;
}) {
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme; const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText }; const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
@@ -239,7 +249,11 @@ function CanvasThemeButton({
} }
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) { 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) { function toolLabel(id: string) {
@@ -25,7 +25,14 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> <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}> <div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}> <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 ? "关闭小地图" : "打开小地图"} /> <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>
<Tooltip title="重置视图"> <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="重置视图" /> <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="重置视图" />
@@ -18,17 +18,7 @@ type InfiniteCanvasProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
export function InfiniteCanvas({ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
containerRef,
viewport,
backgroundMode = "lines",
onViewportChange,
onCanvasMouseDown,
onCanvasDeselect,
onContextMenu,
onDrop,
children,
}: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({ const panState = useRef({
isPanning: false, isPanning: false,
@@ -47,9 +37,12 @@ export function InfiniteCanvas({
scaleRef.current = viewport.k; scaleRef.current = viewport.k;
}, [viewport.k]); }, [viewport.k]);
useEffect(() => () => { useEffect(
() => () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current); if (frameRef.current) cancelAnimationFrame(frameRef.current);
}, []); },
[],
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -205,9 +198,8 @@ function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: Can
const x = viewport.x % gridSize; const x = viewport.x % gridSize;
const y = viewport.y % gridSize; const y = viewport.y % gridSize;
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15; const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
const backgroundImage = mode === "dots" const backgroundImage =
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)` 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)`;
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
return ( return (
<div <div
+19 -5
View File
@@ -54,10 +54,22 @@ export default function CanvasPage() {
<h1 className="mt-3 text-3xl font-semibold"></h1> <h1 className="mt-3 text-3xl font-semibold"></h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}></Button> : null} {selectedIds.length ? (
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}></Button> : null} <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
<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> </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> </div>
</header> </header>
@@ -73,7 +85,9 @@ export default function CanvasPage() {
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800"> <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> <h2 className="text-xl font-medium"></h2>
<p className="mt-3 text-sm text-stone-500">线</p> <p className="mt-3 text-sm text-stone-500">线</p>
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}></Button> <Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
</Button>
</section> </section>
)} )}
</div> </div>
@@ -100,21 +100,25 @@ export const useCanvasStore = create<CanvasStore>()(
openProject: (id) => { openProject: (id) => {
return get().projects.find((item) => item.id === id) || null; return get().projects.find((item) => item.id === id) || null;
}, },
renameProject: (id, title) => set((state) => ({ renameProject: (id, title) =>
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project), 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) => { deleteProjects: (ids) =>
set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id)); const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects }; return { projects };
}), }),
updateProject: (id, patch) => set((state) => ({ updateProject: (id, patch) =>
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project), set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
})), })),
}), }),
{ {
name: CANVAS_STORE_KEY, name: CANVAS_STORE_KEY,
storage: canvasStorage, storage: canvasStorage,
partialize: (state) => ({ partialize: (state) =>
({
projects: state.projects, projects: state.projects,
}) as StorageValue<CanvasStore>["state"], }) as StorageValue<CanvasStore>["state"],
onRehydrateStorage: () => () => { onRehydrateStorage: () => () => {
+2 -3
View File
@@ -111,10 +111,9 @@ export type SelectionBox = {
initialSelectedNodeIds: string[]; initialSelectedNodeIds: string[];
}; };
export type ContextMenuState = export type ContextMenuState = {
{
type: "node"; type: "node";
x: number; x: number;
y: number; y: number;
nodeId: string; nodeId: string;
}; };
@@ -17,13 +17,7 @@ export type ImageAngleTransform = {
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) { export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl); const image = await loadImage(dataUrl);
if (crop) { if (crop) {
return drawCrop( 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));
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 size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2)); const sx = Math.max(0, Math.floor((image.width - size) / 2));
+190 -60
View File
@@ -96,10 +96,12 @@ export default function ImagePage() {
const addReferences = async (files?: FileList | null) => { const addReferences = async (files?: FileList | null) => {
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")); const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
const nextReferences = await Promise.all(imageFiles.map(async (file) => { const nextReferences = await Promise.all(
imageFiles.map(async (file) => {
const image = await uploadImage(file); const image = await uploadImage(file);
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
})); }),
);
setReferences((value) => [...value, ...nextReferences]); setReferences((value) => [...value, ...nextReferences]);
}; };
@@ -111,10 +113,12 @@ export default function ImagePage() {
message.error("剪切板里没有可读取的图片"); message.error("剪切板里没有可读取的图片");
return; return;
} }
const nextReferences = await Promise.all(blobs.map(async (blob, index) => { const nextReferences = await Promise.all(
blobs.map(async (blob, index) => {
const image = await uploadImage(blob); const image = await uploadImage(blob);
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
})); }),
);
setReferences((value) => [...value, ...nextReferences]); setReferences((value) => [...value, ...nextReferences]);
message.success(`已读取 ${nextReferences.length} 张参考图`); message.success(`已读取 ${nextReferences.length} 张参考图`);
} catch { } catch {
@@ -147,15 +151,24 @@ export default function ImagePage() {
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot)); const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
const result = await Promise.allSettled(tasks); const result = await Promise.allSettled(tasks);
const successImages = result const successImages = result.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled").map((item) => item.value);
.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled")
.map((item) => item.value);
const successCount = successImages.length; const successCount = successImages.length;
const failCount = generationCount - successCount; const failCount = generationCount - successCount;
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected"); const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
try { try {
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) })); saveLog(
buildLog({
prompt: text,
model,
config: { ...snapshot.config, count: String(generationCount) },
durationMs: performance.now() - batchStartedAt,
successCount,
failCount,
status: successCount ? "成功" : "失败",
thumbnails: successImages.map((image) => image.dataUrl),
}),
);
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败"); successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
} finally { } finally {
setRunning(false); setRunning(false);
@@ -228,10 +241,12 @@ export default function ImagePage() {
const previewGenerationLog = async (log: GenerationLog) => { const previewGenerationLog = async (log: GenerationLog) => {
setPreviewLog(log); setPreviewLog(log);
setLogsOpen(false); setLogsOpen(false);
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => { const images = await Promise.all(
log.thumbnails.map(async (dataUrl, index) => {
const meta = await readImageMeta(dataUrl); const meta = await readImageMeta(dataUrl);
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) }; return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
})); }),
);
setResults(images.map((image) => ({ id: image.id, status: "success", image }))); setResults(images.map((image) => ({ id: image.id, status: "success", image })));
}; };
@@ -277,7 +292,15 @@ export default function ImagePage() {
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100"> <div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]"> <main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block"> <aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} /> <LogPanel
logs={logs}
selectedLogIds={selectedLogIds}
activeLogId={previewLog?.id}
onSelectedLogIdsChange={setSelectedLogIds}
onCreateSession={createSession}
onDeleteSelected={() => setDeleteConfirmOpen(true)}
onPreviewLog={(log) => void previewGenerationLog(log)}
/>
</aside> </aside>
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]"> <section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
@@ -288,8 +311,12 @@ export default function ImagePage() {
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1> <h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
</div> </div>
<div className="flex shrink-0 gap-2 lg:hidden"> <div className="flex shrink-0 gap-2 lg:hidden">
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}></Button> <Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</Button>
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -299,35 +326,46 @@ export default function ImagePage() {
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold"></span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}></Button> <Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}></Button>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
</Button>
</div> </div>
</div> </div>
<Input.TextArea <Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" />
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={7}
placeholder="描述画面主体、风格、构图、光线和用途"
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span> <span className="text-base font-semibold"></span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}></Button> <Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}></Button>
</Button>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
</Button>
</div> </div>
</div> </div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => { <div
className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"
onWheel={(event) => {
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return; if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
event.preventDefault(); event.preventDefault();
event.currentTarget.scrollLeft += event.deltaY; event.currentTarget.scrollLeft += event.deltaY;
}}> }}
>
{references.map((item) => ( {references.map((item) => (
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800"> <div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" /> <img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图"> <button
type="button"
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex"
onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))}
aria-label="移除参考图"
>
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </div>
@@ -337,8 +375,12 @@ export default function ImagePage() {
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden"> <div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {effectiveConfig.size} · {effectiveConfig.quality}</span> <span className="truncate text-stone-500 dark:text-stone-400">
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button> {model} · {effectiveConfig.size} · {effectiveConfig.quality}
</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
</Button>
</div> </div>
<div className="hidden gap-4 sm:grid sm:grid-cols-2"> <div className="hidden gap-4 sm:grid sm:grid-cols-2">
@@ -362,15 +404,15 @@ export default function ImagePage() {
</div> </div>
{results.length ? ( {results.length ? (
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{results.map((result, index) => ( {results.map((result, index) =>
result.status === "success" && result.image ? ( result.status === "success" && result.image ? (
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} /> <ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
) : result.status === "failed" ? ( ) : result.status === "failed" ? (
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} /> <FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
) : ( ) : (
<PendingImageCard key={result.id} /> <PendingImageCard key={result.id} />
) ),
))} )}
</div> </div>
) : ( ) : (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]"> <div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
@@ -381,12 +423,27 @@ export default function ImagePage() {
</div> </div>
</section> </section>
</main> </main>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => { <input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void addReferences(event.target.files); void addReferences(event.target.files);
event.target.value = ""; event.target.value = "";
}} /> }}
/>
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}> <Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} /> <LogPanel
logs={logs}
selectedLogIds={selectedLogIds}
activeLogId={previewLog?.id}
onSelectedLogIdsChange={setSelectedLogIds}
onCreateSession={createSession}
onDeleteSelected={() => setDeleteConfirmOpen(true)}
onPreviewLog={(log) => void previewGenerationLog(log)}
/>
</Drawer> </Drawer>
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}> <Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -425,20 +482,40 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
); );
} }
function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void }) { function ResultImageCard({
image,
index,
onEdit,
onDownload,
onSaveAsset,
}: {
image: GeneratedImage;
index: number;
onEdit: (image: GeneratedImage, index: number) => void;
onDownload: (image: GeneratedImage, index: number) => void;
onSaveAsset: (image: GeneratedImage, index: number) => void;
}) {
return ( return (
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" /> <Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400"> <div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
<span>{image.width}x{image.height}</span> <span>
{image.width}x{image.height}
</span>
<span>{formatBytes(image.bytes)}</span> <span>{formatBytes(image.bytes)}</span>
<span>{formatDuration(image.durationMs)}</span> <span>{formatDuration(image.durationMs)}</span>
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}></Button> <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}></Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}></Button> </Button>
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>
</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -473,17 +550,35 @@ function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => voi
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950"> <div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
<Button size="small" danger onClick={onRetry}></Button> <Button size="small" danger onClick={onRetry}>
</Button>
</div> </div>
</div> </div>
); );
} }
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) { function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
return results.map((item, itemIndex) => itemIndex === index ? { ...item, ...next } : item); return results.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
} }
function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void }) { function LogPanel({
logs,
selectedLogIds,
activeLogId,
onSelectedLogIdsChange,
onCreateSession,
onDeleteSelected,
onPreviewLog,
}: {
logs: GenerationLog[];
selectedLogIds: string[];
activeLogId?: string;
onSelectedLogIdsChange: (ids: string[]) => void;
onCreateSession: () => void;
onDeleteSelected: () => void;
onPreviewLog: (log: GenerationLog) => void;
}) {
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
@@ -496,9 +591,15 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
<Tag className="m-0">{logs.length}</Tag> <Tag className="m-0">{logs.length}</Tag>
</div> </div>
<div className="mb-4 flex flex-wrap gap-2"> <div className="mb-4 flex flex-wrap gap-2">
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}></Button> <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}></Button> </Button>
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
{allSelected ? "取消" : "全选"}
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
</Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{logs.map((log) => ( {logs.map((log) => (
@@ -511,11 +612,7 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
onClick={() => onPreviewLog(log)} onClick={() => onPreviewLog(log)}
/> />
))} ))}
{!logs.length ? ( {!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700"></div> : null}
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
</div>
) : null}
</div> </div>
</> </>
); );
@@ -523,7 +620,11 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
return ( return (
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}> <button
type="button"
className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`}
onClick={onClick}
>
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2"> <div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} /> <Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
@@ -531,19 +632,29 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
<div className="truncate text-sm font-semibold leading-5">{log.title}</div> <div className="truncate text-sm font-semibold leading-5">{log.title}</div>
{log.thumbnails?.length ? ( {log.thumbnails?.length ? (
<div className="mt-2 flex gap-1 overflow-hidden"> <div className="mt-2 flex gap-1 overflow-hidden">
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)} {log.thumbnails.slice(0, 4).map((image, index) => (
<img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />
))}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="grid justify-items-end gap-2"> <div className="grid justify-items-end gap-2">
<div className="flex gap-1"> <div className="flex gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue"> {log.successCount ?? log.imageCount}</Tag> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red"> {log.failCount}</Tag> : null} {log.successCount ?? log.imageCount}
</Tag>
{log.failCount ? (
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">
{log.failCount}
</Tag>
) : null}
</div> </div>
<div className="flex flex-wrap justify-end gap-1"> <div className="flex flex-wrap justify-end gap-1">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} </Tag> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} </Tag>
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
{formatDuration(log.durationMs)}
</Tag>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag> <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
@@ -563,20 +674,21 @@ async function readStoredLogs() {
window.localStorage.removeItem(LOG_STORE_KEY); window.localStorage.removeItem(LOG_STORE_KEY);
} }
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX))); const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
await Promise.all(legacyKeys.map(async (key) => { await Promise.all(
legacyKeys.map(async (key) => {
try { try {
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || "")); const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
await logStore.setItem(log.id, log); await logStore.setItem(log.id, log);
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
} catch {} } catch {}
})); }),
);
const logs: GenerationLog[] = []; const logs: GenerationLog[] = [];
await logStore.iterate<GenerationLog, void>((value) => { await logStore.iterate<GenerationLog, void>((value) => {
logs.push(normalizeLog(value)); logs.push(normalizeLog(value));
}); });
return logs return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch { } catch {
return []; return [];
} }
@@ -600,7 +712,25 @@ function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
}; };
} }
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog { function buildLog({
prompt,
model,
config,
durationMs,
successCount,
failCount,
status,
thumbnails,
}: {
prompt: string;
model: string;
config: { size: string; quality: string; count?: string };
durationMs: number;
successCount: number;
failCount: number;
status: GenerationLog["status"];
thumbnails: string[];
}): GenerationLog {
return { return {
id: nanoid(), id: nanoid(),
createdAt: Date.now(), createdAt: Date.now(),
+18 -18
View File
@@ -8,15 +8,7 @@ import { fetchPrompts, type Prompt } from "@/services/api/prompts";
import { navigationTools } from "@/constant/navigation-tools"; import { navigationTools } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Highlighter({ function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children: ReactNode }) {
action,
color,
children,
}: {
action: "highlight" | "underline";
color: string;
children: ReactNode;
}) {
return ( return (
<span className="relative inline-block px-1"> <span className="relative inline-block px-1">
{action === "highlight" ? ( {action === "highlight" ? (
@@ -37,7 +29,9 @@ export default function IndexPage() {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
useEffect(() => { useEffect(() => {
void fetchPrompts({ pageSize: 12 }).then((data) => setPromptShowcase(data.items)).catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败")); void fetchPrompts({ pageSize: 12 })
.then((data) => setPromptShowcase(data.items))
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
}, [message]); }, [message]);
return ( return (
@@ -47,11 +41,17 @@ export default function IndexPage() {
<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="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"> <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 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl"></h1>
</h1>
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400"> <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>
<Highlighter action="underline" color="#FF9800">
</Highlighter>
<Highlighter action="highlight" color="#87CEFA">
</Highlighter>
</p> </p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3"> <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 type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
@@ -68,9 +68,7 @@ export default function IndexPage() {
<div /> <div />
<div className="max-w-2xl text-center"> <div className="max-w-2xl text-center">
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h2> <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 className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p>
</p>
</div> </div>
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end"> <Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
@@ -117,7 +115,9 @@ export default function IndexPage() {
}} }}
> >
<div className="hidden"> <div className="hidden">
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)} {promptShowcase.map((item) => (
<Image key={item.id} src={item.coverUrl} alt={item.title} />
))}
</div> </div>
</Image.PreviewGroup> </Image.PreviewGroup>
</main> </main>
+30 -6
View File
@@ -30,7 +30,7 @@ export default function PromptsPage() {
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]); if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]); setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
}; };
const savePromptAsset = (item: Prompt) => { const savePromptAsset = (item: Prompt) => {
@@ -47,13 +47,20 @@ export default function PromptsPage() {
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100"> <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}> <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="pb-8">
<div className="mx-auto max-w-5xl text-center"> <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> <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> <p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</div> </div>
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null} {query.isLoading ? (
<div className="flex h-60 items-center justify-center">
<Spin />
</div>
) : null}
{!query.isLoading ? ( {!query.isLoading ? (
<> <>
<div className="mx-auto mt-8 w-full max-w-2xl"> <div className="mx-auto mt-8 w-full max-w-2xl">
@@ -74,7 +81,12 @@ export default function PromptsPage() {
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{promptTags.map((tag) => ( {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.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}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
@@ -89,11 +101,23 @@ export default function PromptsPage() {
<div> <div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <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) => ( {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>} /> <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> </div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null} {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 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> </div>
) : null} ) : null}
</main> </main>
+14 -9
View File
@@ -141,8 +141,7 @@
@layer utilities { @layer utilities {
.ai-title-aurora { .ai-title-aurora {
background: background: linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
background-size: 220% 100%; background-size: 220% 100%;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
@@ -151,15 +150,15 @@
} }
.dark .ai-title-aurora { .dark .ai-title-aurora {
background: background: linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
background-size: 220% 100%; background-size: 220% 100%;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
} }
@keyframes ai-title-aurora { @keyframes ai-title-aurora {
0%, 100% { 0%,
100% {
background-position: 0% 50%; background-position: 0% 50%;
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0)); filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
} }
@@ -186,7 +185,11 @@
color: #57534e; color: #57534e;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; 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 { .prompt-filter-tag.ant-tag-checkable:hover {
@@ -215,7 +218,9 @@
.dark .prompt-filter-tag.ant-tag-checkable-checked { .dark .prompt-filter-tag.ant-tag-checkable-checked {
background: rgba(250, 250, 249, 0.96); background: rgba(250, 250, 249, 0.96);
border-color: rgba(255, 255, 255, 0.88); 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); box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.18),
0 6px 18px rgba(0, 0, 0, 0.22);
color: #0c0a09 !important; color: #0c0a09 !important;
font-weight: 700; font-weight: 700;
} }
@@ -396,10 +401,10 @@
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72); 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; animation: none;
mix-blend-mode: normal; mix-blend-mode: normal;
} }
+1 -2
View File
@@ -21,8 +21,7 @@ export default function RootLayout({
<body <body
className="bg-background text-foreground antialiased" className="bg-background text-foreground antialiased"
style={{ style={{
fontFamily: fontFamily: '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
}} }}
> >
<Script <Script
+6 -7
View File
@@ -6,19 +6,18 @@ export default function NotFound() {
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground"> <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"> <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"> <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"> <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>
404
</div>
<h1 className="text-3xl font-semibold tracking-normal"></h1> <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 className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400"></p>
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3"> <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"> <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" /> <Home className="size-4" />
</Link> </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"> <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" /> <LogIn className="size-4" />
</Link> </Link>
+18 -4
View File
@@ -54,12 +54,21 @@ export function AppConfigModal() {
return ( return (
<Modal <Modal
title={<div><div className="text-lg font-semibold"></div><div className="mt-1 text-xs font-normal text-stone-500"></div></div>} title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen} open={isConfigOpen}
width={760} width={760}
centered centered
onCancel={() => setConfigDialogOpen(false)} onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" onClick={finishConfig}></Button>} footer={
<Button type="primary" onClick={finishConfig}>
</Button>
}
> >
<div className="pt-1"> <div className="pt-1">
<Form layout="vertical" requiredMark={false}> <Form layout="vertical" requiredMark={false}>
@@ -70,7 +79,10 @@ export function AppConfigModal() {
size="middle" size="middle"
value={effectiveMode} value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])} onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]} options={[
{ label: "本地直连", value: "local" },
{ label: "云端渠道", value: "remote" },
]}
/> />
</Form.Item> </Form.Item>
) : null} ) : null}
@@ -89,7 +101,9 @@ export function AppConfigModal() {
<div className="text-sm font-medium"></div> <div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div> <div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div> </div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}></Button> <Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div> </div>
</> </>
) : ( ) : (
+1 -5
View File
@@ -35,10 +35,7 @@ export function AppTopNav() {
<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"> <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="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
<div className="flex min-w-0 items-center"> <div className="flex min-w-0 items-center">
<Link <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">
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 <span
className="size-5 shrink-0 bg-current" className="size-5 shrink-0 bg-current"
style={{ style={{
@@ -117,7 +114,6 @@ export function AppTopNav() {
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} /> <MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
<AppConfigModal /> <AppConfigModal />
</> </>
); );
} }
@@ -26,9 +26,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
onClick={onClose} onClick={onClose}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition", "flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
active 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",
? "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" /> <Icon className="size-5" />
@@ -25,15 +25,7 @@ type UserStatusActionsProps = {
getPopupContainer?: (node: HTMLElement) => HTMLElement; getPopupContainer?: (node: HTMLElement) => HTMLElement;
}; };
export function UserStatusActions({ export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) {
showConfig = true,
variant = "default",
onOpenShortcuts,
accountOpen,
onAccountOpenChange,
accountRef,
getPopupContainer,
}: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
@@ -59,37 +51,15 @@ export function UserStatusActions({
return ( return (
<div className="inline-flex shrink-0 items-center gap-1.5"> <div className="inline-flex shrink-0 items-center gap-1.5">
{showConfig ? ( {showConfig ? (
<button <button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
type="button"
className={naturalIconClass}
style={iconStyle}
onClick={() => openConfigDialog(false)}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" /> <Settings2 className="size-4" />
</button> </button>
) : null} ) : null}
<AnimatedThemeToggler <AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
theme={theme}
onThemeChange={setTheme}
className={naturalIconClass}
style={iconStyle}
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<VersionReleaseModal style={versionStyle} /> <VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} /> <GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}> <div ref={accountRef}>
<Dropdown <Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
open={accountOpen}
onOpenChange={onAccountOpenChange}
trigger={["click"]}
placement="bottomRight"
getPopupContainer={getPopupContainer}
styles={{ root: { minWidth: 150 } }}
menu={{ items: menuItems }}
>
<button <button
type="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" 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"
@@ -39,14 +39,7 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null} {hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span> </span>
</button> </button>
<Modal <Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
title="版本更新"
open={open}
width={680}
centered
footer={null}
onCancel={() => setOpen(false)}
>
<div className="mb-5 grid grid-cols-2 gap-3"> <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="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="text-xs text-stone-500 dark:text-stone-400"></div>
@@ -82,7 +75,9 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
{release.items.map((item, index) => ( {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"> <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> <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> <span className="min-w-0 flex-1">{item.content}</span>
</div> </div>
))} ))}
+16 -3
View File
@@ -25,7 +25,11 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
className={`canvas-control-select ${className || ""}`} className={`canvas-control-select ${className || ""}`}
classNames={{ popup: { root: styles.dropdown } }} classNames={{ popup: { root: styles.dropdown } }}
popupMatchSelectWidth popupMatchSelectWidth
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>} popupRender={(menu) => (
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }} style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
value={value || undefined} value={value || undefined}
placeholder={placeholder} placeholder={placeholder}
@@ -37,14 +41,23 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
onClick={() => { onClick={() => {
if (!options.length) onMissingConfig?.(); if (!options.length) onMissingConfig?.();
}} }}
filterOption={(input, option) => String(option?.value || "").toLowerCase().includes(input.toLowerCase())} filterOption={(input, option) =>
String(option?.value || "")
.toLowerCase()
.includes(input.toLowerCase())
}
/> />
); );
} }
function ModelLabel({ model }: { model: string }) { function ModelLabel({ model }: { model: string }) {
const icon = resolveModelIcon(model); 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>; 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) { function resolveModelIcon(model: string) {
+30 -4
View File
@@ -6,13 +6,33 @@ import { Button, Card, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts"; 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 }) { 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 ( return (
<Card <Card
hoverable hoverable
className="overflow-hidden" className="overflow-hidden"
styles={{ body: { padding: 0 } }} 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>} 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}> <button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4"> <div className="p-4">
@@ -22,12 +42,18 @@ export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actio
</div> </div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p> <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"> <div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)} {item.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
</div> </div>
</div> </div>
</button> </button>
<div className="flex items-center gap-2 px-4 pb-4"> <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> <Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
{actionLabel}
</Button>
{extraAction} {extraAction}
</div> </div>
</Card> </Card>
@@ -17,12 +17,26 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
{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} {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>
<div className="min-w-0"> <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> <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> <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> <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"> <Space wrap className="mt-5">
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}></Button> <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}></Button> : null}
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space> </Space>
</div> </div>
</div> </div>
@@ -17,7 +17,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open }); const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
const toggleTag = (tag: string) => { const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]); if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]); setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
}; };
const selectPrompt = (prompt: string) => { const selectPrompt = (prompt: string) => {
onSelect(prompt); onSelect(prompt);
@@ -65,14 +65,22 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
</div> </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()}> <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} {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"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => ( {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" /> <PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
))} ))}
</div> </div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null} {!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} {query.isFetchingNextPage ? (
<div className="py-4 text-center">
<Spin size="small" />
</div>
) : null}
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -12,7 +12,7 @@ export function usePromptList({ keyword, tags, category, enabled = true }: { key
queryKey: ["prompts", keyword, tags, category], queryKey: ["prompts", keyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }), queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined, getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
enabled, enabled,
}); });
const firstPage = query.data?.pages[0]; const firstPage = query.data?.pages[0];
+104 -179
View File
@@ -1,241 +1,172 @@
"use client" "use client";
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react";
import { Moon, Sun } from "lucide-react" import { Moon, Sun } from "lucide-react";
import { flushSync } from "react-dom" import { flushSync } from "react-dom";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
export type TransitionVariant = export type TransitionVariant = "circle" | "square" | "triangle" | "diamond" | "hexagon" | "rectangle" | "star";
| "circle"
| "square"
| "triangle"
| "diamond"
| "hexagon"
| "rectangle"
| "star"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> { interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number duration?: number;
variant?: TransitionVariant variant?: TransitionVariant;
/** When true, the transition expands from the viewport center instead of the button center. */ /** When true, the transition expands from the viewport center instead of the button center. */
fromCenter?: boolean fromCenter?: boolean;
theme?: "light" | "dark" theme?: "light" | "dark";
targetTheme?: "light" | "dark" targetTheme?: "light" | "dark";
onThemeChange?: (theme: "light" | "dark") => void onThemeChange?: (theme: "light" | "dark") => void;
} }
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string { function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
const pairs = Array.from( const pairs = Array.from({ length: vertexCount }, () => `${cx}px ${cy}px`).join(", ");
{ length: vertexCount }, return `polygon(${pairs})`;
() => `${cx}px ${cy}px`
).join(", ")
return `polygon(${pairs})`
} }
function getThemeTransitionClipPaths( function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy: number, maxRadius: number, viewportWidth: number, viewportHeight: number): [string, string] {
variant: TransitionVariant,
cx: number,
cy: number,
maxRadius: number,
viewportWidth: number,
viewportHeight: number
): [string, string] {
switch (variant) { switch (variant) {
case "circle": case "circle":
return [ return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
case "square": { case "square": {
const halfW = Math.max(cx, viewportWidth - cx) const halfW = Math.max(cx, viewportWidth - cx);
const halfH = Math.max(cy, viewportHeight - cy) const halfH = Math.max(cy, viewportHeight - cy);
const halfSide = Math.max(halfW, halfH) * 1.05 const halfSide = Math.max(halfW, halfH) * 1.05;
const end = [ 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(", ");
`${cx - halfSide}px ${cy - halfSide}px`, return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
`${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": { case "triangle": {
const scale = maxRadius * 2.2 const scale = maxRadius * 2.2;
const dx = (Math.sqrt(3) / 2) * scale const dx = (Math.sqrt(3) / 2) * scale;
const verts = [ const verts = [`${cx}px ${cy - scale}px`, `${cx + dx}px ${cy + 0.5 * scale}px`, `${cx - dx}px ${cy + 0.5 * scale}px`].join(", ");
`${cx}px ${cy - scale}px`, return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`];
`${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": { case "diamond": {
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal. // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
const R = maxRadius * Math.SQRT2 const R = maxRadius * Math.SQRT2;
const end = [ const end = [`${cx}px ${cy - R}px`, `${cx + R}px ${cy}px`, `${cx}px ${cy + R}px`, `${cx - R}px ${cy}px`].join(", ");
`${cx}px ${cy - R}px`, return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
`${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": { case "hexagon": {
const R = maxRadius * Math.SQRT2 const R = maxRadius * Math.SQRT2;
const verts: string[] = [] const verts: string[] = [];
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3 const a = -Math.PI / 2 + (i * Math.PI) / 3;
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`) verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`);
} }
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`] return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`];
} }
case "rectangle": { case "rectangle": {
const halfW = Math.max(cx, viewportWidth - cx) const halfW = Math.max(cx, viewportWidth - cx);
const halfH = Math.max(cy, viewportHeight - cy) const halfH = Math.max(cy, viewportHeight - cy);
const end = [ 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(", ");
`${cx - halfW}px ${cy - halfH}px`, return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
`${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": { case "star": {
// Small overscan so the last frames never leave a 1px seam before the transition group ends. // Small overscan so the last frames never leave a 1px seam before the transition group ends.
const R = maxRadius * Math.SQRT2 * 1.03 const R = maxRadius * Math.SQRT2 * 1.03;
const innerRatio = 0.42 const innerRatio = 0.42;
const starPolygon = (radius: number) => { const starPolygon = (radius: number) => {
const verts: string[] = [] const verts: string[] = [];
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5 const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
verts.push( verts.push(`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`);
`${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`);
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(", ")})` return `polygon(${verts.join(", ")})`;
} };
const startR = Math.max(2, R * 0.025) const startR = Math.max(2, R * 0.025);
return [starPolygon(startR), starPolygon(R)] return [starPolygon(startR), starPolygon(R)];
} }
default: default:
return [ return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
} }
} }
export const AnimatedThemeToggler = ({ export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => {
children, const shape = variant ?? "circle";
className, const [isDark, setIsDark] = useState(false);
duration = 400, const buttonRef = useRef<HTMLButtonElement>(null);
variant,
fromCenter = false,
theme,
targetTheme,
onThemeChange,
...props
}: AnimatedThemeTogglerProps) => {
const shape = variant ?? "circle"
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {
if (theme) { if (theme) {
setIsDark(theme === "dark") setIsDark(theme === "dark");
return return;
} }
const updateTheme = () => { const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark")) setIsDark(document.documentElement.classList.contains("dark"));
} };
updateTheme() updateTheme();
const observer = new MutationObserver(updateTheme) const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ["class"], attributeFilter: ["class"],
}) });
return () => observer.disconnect() return () => observer.disconnect();
}, [theme]) }, [theme]);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
const button = buttonRef.current const button = buttonRef.current;
if (!button) return if (!button) return;
const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
const viewportHeight = window.visualViewport?.height ?? window.innerHeight const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
let x: number let x: number;
let y: number let y: number;
if (fromCenter) { if (fromCenter) {
x = viewportWidth / 2 x = viewportWidth / 2;
y = viewportHeight / 2 y = viewportHeight / 2;
} else { } else {
const { top, left, width, height } = button.getBoundingClientRect() const { top, left, width, height } = button.getBoundingClientRect();
x = left + width / 2 x = left + width / 2;
y = top + height / 2 y = top + height / 2;
} }
const maxRadius = Math.hypot( const maxRadius = Math.hypot(Math.max(x, viewportWidth - x), Math.max(y, viewportHeight - y));
Math.max(x, viewportWidth - x),
Math.max(y, viewportHeight - y)
)
const applyTheme = () => { const applyTheme = () => {
const nextTheme = targetTheme ?? (isDark ? "light" : "dark") const nextTheme = targetTheme ?? (isDark ? "light" : "dark");
if (nextTheme === (isDark ? "dark" : "light")) return if (nextTheme === (isDark ? "dark" : "light")) return;
setIsDark(nextTheme === "dark") setIsDark(nextTheme === "dark");
document.documentElement.classList.toggle("dark", nextTheme === "dark") document.documentElement.classList.toggle("dark", nextTheme === "dark");
document.documentElement.style.colorScheme = nextTheme document.documentElement.style.colorScheme = nextTheme;
onThemeChange?.(nextTheme) onThemeChange?.(nextTheme);
} };
if (typeof document.startViewTransition !== "function") { if (typeof document.startViewTransition !== "function") {
applyTheme() applyTheme();
return return;
} }
const clipPath = getThemeTransitionClipPaths( const clipPath = getThemeTransitionClipPaths(shape, x, y, maxRadius, viewportWidth, viewportHeight);
shape,
x,
y,
maxRadius,
viewportWidth,
viewportHeight
)
const root = document.documentElement const root = document.documentElement;
root.dataset.magicuiThemeVt = "active" root.dataset.magicuiThemeVt = "active";
root.style.setProperty( root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
"--magicui-theme-toggle-vt-duration",
`${duration}ms`
)
// Pin the collapsed clip-path via CSS so Firefox does not paint the new // Pin the collapsed clip-path via CSS so Firefox does not paint the new
// theme unclipped between snapshot and the ready.then() JS animation. // theme unclipped between snapshot and the ready.then() JS animation.
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
const cleanup = () => { const cleanup = () => {
delete root.dataset.magicuiThemeVt delete root.dataset.magicuiThemeVt;
root.style.removeProperty("--magicui-theme-toggle-vt-duration") root.style.removeProperty("--magicui-theme-toggle-vt-duration");
root.style.removeProperty("--magicui-theme-vt-clip-from") root.style.removeProperty("--magicui-theme-vt-clip-from");
} };
const transition = document.startViewTransition(() => { const transition = document.startViewTransition(() => {
flushSync(applyTheme) flushSync(applyTheme);
}) });
if (typeof transition?.finished?.finally === "function") { if (typeof transition?.finished?.finally === "function") {
transition.finished.finally(cleanup) transition.finished.finally(cleanup);
} else { } else {
cleanup() cleanup();
} }
const ready = transition?.ready const ready = transition?.ready;
if (ready && typeof ready.then === "function") { if (ready && typeof ready.then === "function") {
ready.then(() => { ready.then(() => {
document.documentElement.animate( document.documentElement.animate(
@@ -248,22 +179,16 @@ export const AnimatedThemeToggler = ({
easing: shape === "star" ? "linear" : "ease-in-out", easing: shape === "star" ? "linear" : "ease-in-out",
fill: "forwards", fill: "forwards",
pseudoElement: "::view-transition-new(root)", pseudoElement: "::view-transition-new(root)",
},
);
});
} }
) }, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange]);
})
}
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
return ( return (
<button <button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
type="button"
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{children ?? (isDark ? <Sun /> : <Moon />)} {children ?? (isDark ? <Sun /> : <Moon />)}
<span className="sr-only">{props["aria-label"] || "切换主题"}</span> <span className="sr-only">{props["aria-label"] || "切换主题"}</span>
</button> </button>
) );
} };
+85 -120
View File
@@ -1,148 +1,121 @@
"use client" "use client";
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react";
import { import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
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 DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"];
const BAND_HALF = 17 const BAND_HALF = 17;
const SWEEP_START = -BAND_HALF const SWEEP_START = -BAND_HALF;
const SWEEP_END = 100 + BAND_HALF const SWEEP_END = 100 + BAND_HALF;
const sweepEase = (t: number) => const sweepEase = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
function buildGradient(pos: number, colors: string[], textColor: string) { function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF const bandStart = pos - BAND_HALF;
const bandEnd = pos + BAND_HALF const bandEnd = pos + BAND_HALF;
if (bandStart >= 100) { if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})` return `linear-gradient(90deg, ${textColor}, ${textColor})`;
} }
const n = colors.length const n = colors.length;
const parts: string[] = [] const parts: string[] = [];
if (bandStart > 0) if (bandStart > 0) parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`);
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
colors.forEach((c, i) => { colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2 const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2;
parts.push(`${c} ${pct.toFixed(2)}%`) parts.push(`${c} ${pct.toFixed(2)}%`);
}) });
if (bandEnd < 100) if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 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[]) { function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement const ghost = el.cloneNode() as HTMLElement;
Object.assign(ghost.style, { Object.assign(ghost.style, {
position: "absolute", position: "absolute",
visibility: "hidden", visibility: "hidden",
pointerEvents: "none", pointerEvents: "none",
width: "auto", width: "auto",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}) });
el.parentElement!.appendChild(ghost) el.parentElement!.appendChild(ghost);
const widths = texts.map((t) => { const widths = texts.map((t) => {
ghost.textContent = t ghost.textContent = t;
return ghost.getBoundingClientRect().width return ghost.getBoundingClientRect().width;
}) });
ghost.remove() ghost.remove();
return widths return widths;
} }
/** /**
* Props for {@link DiaTextReveal}. * Props for {@link DiaTextReveal}.
*/ */
export interface DiaTextRevealProps extends Omit< export interface DiaTextRevealProps extends Omit<HTMLMotionProps<"span">, "ref" | "children" | "style" | "animate" | "transition" | "color"> {
HTMLMotionProps<"span">,
"ref" | "children" | "style" | "animate" | "transition" | "color"
> {
/** /**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`. * Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/ */
text: string | string[] text: string | string[];
/** /**
* Colors sampled across the moving gradient band. Defaults to a built-in palette. * Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/ */
colors?: string[] colors?: string[];
/** /**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation. * CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"` * @defaultValue `"var(--foreground)"`
*/ */
textColor?: string textColor?: string;
/** /**
* Duration of one sweep pass, in seconds. * Duration of one sweep pass, in seconds.
* @defaultValue `1.5` * @defaultValue `1.5`
*/ */
duration?: number duration?: number;
/** /**
* Delay before the sweep starts, in seconds. * Delay before the sweep starts, in seconds.
* @defaultValue `0` * @defaultValue `0`
*/ */
delay?: number delay?: number;
/** /**
* When `text` is an array, replay the sweep and advance to the next string after each completion. * When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false` * @defaultValue `false`
*/ */
repeat?: boolean repeat?: boolean;
/** /**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds. * Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5` * @defaultValue `0.5`
*/ */
repeatDelay?: number repeatDelay?: number;
/** /**
* If `true`, the animation starts only after the element enters the viewport. * If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true` * @defaultValue `true`
*/ */
startOnView?: boolean startOnView?: boolean;
/** /**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back). * Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true` * @defaultValue `true`
*/ */
once?: boolean once?: boolean;
/** /**
* Additional class names for the animated `span` (e.g. typography utilities). * Additional class names for the animated `span` (e.g. typography utilities).
*/ */
className?: string className?: string;
/** /**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line. * When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false` * @defaultValue `false`
*/ */
fixedWidth?: boolean fixedWidth?: boolean;
} }
export function DiaTextReveal({ 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) {
text, const texts = Array.isArray(text) ? text : [text];
colors = DEFAULT_COLORS, const isMulti = texts.length > 1;
textColor = "var(--foreground)", const prefersReducedMotion = useReducedMotion();
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 spanRef = useRef<HTMLSpanElement>(null);
const optsRef = useRef({ const optsRef = useRef({
colors, colors,
textColor, textColor,
@@ -151,7 +124,7 @@ export function DiaTextReveal({
repeat, repeat,
repeatDelay, repeatDelay,
texts, texts,
}) });
optsRef.current = { optsRef.current = {
colors, colors,
textColor, textColor,
@@ -160,79 +133,71 @@ export function DiaTextReveal({
repeat, repeat,
repeatDelay, repeatDelay,
texts, texts,
} };
const indexRef = useRef(0) const indexRef = useRef(0);
const hasPlayedRef = useRef(false) const hasPlayedRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined) const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const playRef = useRef<() => void>(null!) const playRef = useRef<() => void>(null!);
const stopRef = useRef<(() => void) | null>(null) const stopRef = useRef<(() => void) | null>(null);
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0);
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]) const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
const sweepPos = useMotionValue(SWEEP_START) const sweepPos = useMotionValue(SWEEP_START);
const backgroundImage = useTransform(sweepPos, (pos) => const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
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(() => { useEffect(() => {
const el = spanRef.current const el = spanRef.current;
if (!el || !isMulti) return if (!el || !isMulti) return;
setMeasuredWidths(measureWidths(el, texts)) setMeasuredWidths(measureWidths(el, texts));
}, [Array.isArray(text) ? text.join("\0") : text]) }, [Array.isArray(text) ? text.join("\0") : text]);
playRef.current = () => { playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
sweepPos.set(SWEEP_START) sweepPos.set(SWEEP_START);
const controls = animate(sweepPos, SWEEP_END, { const controls = animate(sweepPos, SWEEP_END, {
duration, duration,
delay, delay,
ease: sweepEase, ease: sweepEase,
onComplete() { onComplete() {
if (!repeat) return if (!repeat) return;
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length const next = (indexRef.current + 1) % texts.length;
indexRef.current = next indexRef.current = next;
setActiveIndex(next) setActiveIndex(next);
playRef.current() playRef.current();
}, repeatDelay * 1000) }, repeatDelay * 1000);
}, },
}) });
stopRef.current = () => controls.stop() stopRef.current = () => controls.stop();
} };
useEffect(() => { useEffect(() => {
if (prefersReducedMotion) { if (prefersReducedMotion) {
sweepPos.set(SWEEP_END) sweepPos.set(SWEEP_END);
return return;
} }
if (startOnView && !isInView) return if (startOnView && !isInView) return;
if (once && hasPlayedRef.current) return if (once && hasPlayedRef.current) return;
hasPlayedRef.current = true hasPlayedRef.current = true;
playRef.current() playRef.current();
return () => { return () => {
stopRef.current?.() stopRef.current?.();
clearTimeout(timerRef.current) clearTimeout(timerRef.current);
} };
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]) }, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
const fixedW = const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
isMulti && fixedWidth && measuredWidths.length > 0
? Math.max(...measuredWidths)
: undefined
const animatedW = const animatedW = isMulti && !fixedWidth && measuredWidths[activeIndex] != null ? measuredWidths[activeIndex] : undefined;
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
? measuredWidths[activeIndex]
: undefined
return ( return (
<motion.span <motion.span
@@ -259,5 +224,5 @@ export function DiaTextReveal({
> >
{texts[activeIndex]} {texts[activeIndex]}
</motion.span> </motion.span>
) );
} }
+6 -6
View File
@@ -48,13 +48,11 @@ export function useVersionCheck() {
} }
}, [currentVersion]); }, [currentVersion]);
const checkLatestRelease = useCallback(async (showMessage = false) => { const checkLatestRelease = useCallback(
async (showMessage = false) => {
setChecking(true); setChecking(true);
try { try {
const [versionResponse, changelogResponse] = await Promise.all([ const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]);
fetch(latestVersionUrl),
fetch(latestChangelogUrl),
]);
if (!versionResponse.ok) throw new Error("版本读取失败"); if (!versionResponse.ok) throw new Error("版本读取失败");
if (!changelogResponse.ok) throw new Error("更新日志读取失败"); if (!changelogResponse.ok) throw new Error("更新日志读取失败");
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]); const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
@@ -70,7 +68,9 @@ export function useVersionCheck() {
} finally { } finally {
setChecking(false); setChecking(false);
} }
}, [currentVersion, localReleases, message]); },
[currentVersion, localReleases, message],
);
useEffect(() => { useEffect(() => {
void checkLatestVersion(); void checkLatestVersion();
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+1 -5
View File
@@ -71,11 +71,7 @@ export type AdminAssetQuery = {
}; };
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) { export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
return apiGet<AdminAssetListResponse>( return apiGet<AdminAssetListResponse>("/api/admin/assets", compactApiParams(query), token);
"/api/admin/assets",
compactApiParams(query),
token,
);
} }
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) { export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
+7 -2
View File
@@ -56,7 +56,10 @@ function readAxiosError(error: unknown, fallback: string) {
function parseStreamChunk(chunk: string, onDelta: (value: string) => void) { function parseStreamChunk(chunk: string, onDelta: (value: string) => void) {
let deltaText = ""; let deltaText = "";
for (const eventBlock of chunk.split("\n\n")) { for (const eventBlock of chunk.split("\n\n")) {
const data = eventBlock.split("\n").find((line) => line.startsWith("data: "))?.slice(6); const data = eventBlock
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6);
if (!data || data === "[DONE]") continue; if (!data || data === "[DONE]") continue;
const delta = (JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content || ""; const delta = (JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content || "";
deltaText += delta; deltaText += delta;
@@ -75,7 +78,9 @@ function aiApiUrl(config: AiConfig, path: string) {
function aiHeaders(config: AiConfig, contentType?: string) { function aiHeaders(config: AiConfig, contentType?: string) {
return config.channelMode === "remote" return config.channelMode === "remote"
? contentType ? { "Content-Type": contentType } : undefined ? contentType
? { "Content-Type": contentType }
: undefined
: { : {
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}), ...(contentType ? { "Content-Type": contentType } : {}),
+5 -2
View File
@@ -23,13 +23,16 @@ export type PromptListResponse = {
}; };
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: 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({ return apiGet<PromptListResponse>(
"/api/prompts",
compactApiParams({
...(keyword ? { keyword } : {}), ...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}), ...(tag.length ? { tag } : {}),
...(category !== ALL_PROMPTS_OPTION ? { category } : {}), ...(category !== ALL_PROMPTS_OPTION ? { category } : {}),
...(page ? { page } : {}), ...(page ? { page } : {}),
...(pageSize ? { pageSize } : {}), ...(pageSize ? { pageSize } : {}),
})); }),
);
} }
export function formatPromptDate(value: string) { export function formatPromptDate(value: string) {
+1 -3
View File
@@ -9,9 +9,7 @@ type ApiResponse<T> = {
}; };
export function compactApiParams(params: ApiParams) { export function compactApiParams(params: ApiParams) {
return Object.fromEntries( return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0)),
) as ApiParams;
} }
export function serializeApiParams(params?: ApiParams) { export function serializeApiParams(params?: ApiParams) {
+6 -4
View File
@@ -39,18 +39,20 @@ export async function resolveImageUrl(storageKey?: string, fallback = "") {
} }
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) { export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) {
const url = image.dataUrl || await resolveImageUrl(image.storageKey, image.url || ""); const url = image.dataUrl || (await resolveImageUrl(image.storageKey, image.url || ""));
if (!url || url.startsWith("data:")) return url; if (!url || url.startsWith("data:")) return url;
return blobToDataUrl(await (await fetch(url)).blob()); return blobToDataUrl(await (await fetch(url)).blob());
} }
export async function deleteStoredImages(keys: Iterable<string>) { export async function deleteStoredImages(keys: Iterable<string>) {
await Promise.all(Array.from(new Set(keys)).map(async (key) => { await Promise.all(
Array.from(new Set(keys)).map(async (key) => {
const url = objectUrls.get(key); const url = objectUrls.get(key);
if (url) URL.revokeObjectURL(url); if (url) URL.revokeObjectURL(url);
objectUrls.delete(key); objectUrls.delete(key);
await store.removeItem(key); await store.removeItem(key);
})); }),
);
} }
export async function cleanupUnusedImages(usedData: unknown) { export async function cleanupUnusedImages(usedData: unknown) {
@@ -65,7 +67,7 @@ export async function cleanupUnusedImages(usedData: unknown) {
export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) { export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") 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); 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)); Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys)));
return keys; return keys;
} }
+11 -6
View File
@@ -40,9 +40,11 @@ const assetStorage: PersistStorage<AssetStore> = {
const value = await localForageStorage.getItem(name); const value = await localForageStorage.getItem(name);
if (!value) return null; if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<AssetStore>; const parsed = JSON.parse(value) as StorageValue<AssetStore>;
parsed.state.assets = await Promise.all(parsed.state.assets.map(async (asset) => { parsed.state.assets = await Promise.all(
parsed.state.assets.map(async (asset) => {
if (asset.kind !== "image") return asset; if (asset.kind !== "image") return asset;
if (asset.data.storageKey) return { if (asset.data.storageKey)
return {
...asset, ...asset,
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl, 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) }, data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
@@ -50,7 +52,8 @@ const assetStorage: PersistStorage<AssetStore> = {
if (!asset.data.dataUrl.startsWith("data:image/")) return asset; if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
const image = await uploadImage(asset.data.dataUrl); 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 { ...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; return parsed;
}, },
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)), setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
@@ -67,10 +70,12 @@ export const useAssetStore = create<AssetStore>()(
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] })); set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] }));
return id; return id;
}, },
updateAsset: (id, patch) => set((state) => ({ updateAsset: (id, patch) =>
assets: state.assets.map((asset) => asset.id === id ? { ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset : asset), set((state) => ({
assets: state.assets.map((asset) => (asset.id === id ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
})), })),
removeAsset: (id) => set((state) => { removeAsset: (id) =>
set((state) => {
const assets = state.assets.filter((asset) => asset.id !== id); const assets = state.assets.filter((asset) => asset.id !== id);
get().cleanupImages({ assets }); get().cleanupImages({ assets });
return { assets }; return { assets };
+4 -19
View File
@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,20 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", "next.config.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"next.config.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }