Compare commits

...

11 Commits

Author SHA1 Message Date
HouYunFei 83031e0864 style(canvas): 调整画布组件样式和交互行为
- 更新CSS选择器以正确隐藏和显示画布工具栏元素
- 为模型选择器下拉菜单添加data-canvas-no-zoom属性防止缩放冲突
- 在模型选择器中阻止指针和鼠标事件传播避免意外操作
- 为模型图标添加深色模式反转效果提升视觉一致性
2026-05-23 16:18:14 +08:00
HouYunFei 593feddc3f refactor(canvas): 优化模型选择器和定时同步配置
- 将模型选择器替换为统一的 shadcn 风格下拉组件
- 移除原生数字输入框的步进箭头样式
- 更新默认定时同步配置为每5分钟执行
- 修复画布助手和节点配置中的模型选择逻辑
- 统一各页面中的模型选择器样式和交互行为
2026-05-23 15:56:17 +08:00
HouYunFei c9682481d7 Merge remote-tracking branch 'origin/main' 2026-05-23 15:43:33 +08:00
kunkun 2e4c0d0f0a Merge pull request #8 from 1847123733/main
feat(canvas): 添加图片预览功能
2026-05-23 15:43:22 +08:00
王鑫凯 697e1fc1eb feat(canvas): 添加图片预览功能
- 新增 previewNodeId 状态用于管理图片预览弹窗
- 在 CanvasNodeHoverToolbar 中添加查看大图按钮
- 实现图片详情模态框组件
- 添加图片预览功能的回调处理逻辑
2026-05-23 15:32:44 +08:00
HouYunFei ca4e87bddf refactor(canvas): 重构画布助手面板组件结构
- 移除 Segmented 组件并替换为自定义模式切换按钮
- 替换 ModelPicker 为 ComposerModelPill 组件
- 添加下拉菜单实现模型选择功能
- 优化 CSS 样式类和响应式布局
- 更新图标引入和工具栏组件结构
- 简化提示词库按钮样式配置
2026-05-23 15:08:13 +08:00
HouYunFei e319481976 style(code): 格式化代码缩进和布局样式
- 统一调整 admin.ts 文件中的接口定义缩进格式
- 优化 animated-theme-toggler.tsx 组件的代码结构和缩进
- 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式
- 整理 app-providers.tsx 中的组件层级缩进
- 标准化 app-theme.ts 中的对象属性缩进格式
2026-05-22 23:19:25 +08:00
HouYunFei cee08cb5a8 feat(canvas): 优化模型选择器和画布助手设置面板
- 模型选择器添加模糊匹配和图标显示支持OpenAI、Claude、Gemini
- 模型选择器下拉滚动条改为更窄的轻量样式
- 画布助手生图设置弹层调整为紧凑图像设置面板避免裁切问题
- 画布节点设置面板打开时隐藏悬浮工具条避免遮挡
2026-05-22 22:14:30 +08:00
HouYunFei 133861590e refactor(canvas): 重构画布图像设置组件为独立面板
- 将图像设置弹层提取为独立的 CanvasImageSettingsPopover 组件
- 在节点对话框中集成新的紧凑图像设置面板
- 移除旧的 ImageSettingsPopover 内联实现
- 更新画布助手面板以使用新组件
- 修复节点工具栏在图像设置打开时的显示逻辑
- 调整节点配置中的质量属性继承顺序
2026-05-22 17:49:55 +08:00
HouYunFei 6049ad2df3 feat(admin): 添加提示词批量删除和定时同步功能
- 后端新增批量删除提示词接口和实现
- 前端管理页面添加多选和批量删除功能
- 添加定时同步GitHub远程提示词源功能
- 新增系统设置中的提示词同步配置选项
- 更新文档说明新的批量操作和定时同步功能
- 添加相关的测试用例和依赖库
2026-05-22 16:11:55 +08:00
HouYunFei 03396a9d18 feat(admin): 添加提示词批量删除和定时同步功能
- 后端新增批量删除提示词接口和实现
- 前端管理页面添加多选和批量删除功能
- 添加定时同步GitHub远程提示词源功能
- 新增系统设置中的提示词同步配置选项
- 更新文档说明新的批量操作和定时同步功能
- 添加相关的测试用例和依赖库
2026-05-22 16:01:00 +08:00
113 changed files with 11975 additions and 10274 deletions
+9
View File
@@ -2,6 +2,15 @@
## Unreleased ## Unreleased
## v0.0.7 - 2026-05-23
+ [新增] 管理后台提示词管理支持多选批量删除。
+ [新增] 新增定义拉取GitHub提示词源功能。
+ [新增] 新增awesome-gpt-image2-prompts提示词来源。
+ [优化] 优化模型下拉选择样式、优化生图编辑设置
## v0.0.6 - 2026-05-22
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。 + [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。 + [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
+ [优化] 后端模型代理路径调整为 OpenAI 风格。 + [优化] 后端模型代理路径调整为 OpenAI 风格。
+8
View File
@@ -129,6 +129,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------------|----------|----------| |------------|----------|----------|
| `channels` | object[] | 模型渠道配置列表 | | `channels` | object[] | 模型渠道配置列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
`channels` 每项字段: `channels` 每项字段:
@@ -143,6 +144,13 @@
| `enabled` | bool | 是否启用 | | `enabled` | bool | 是否启用 |
| `remark` | string | 备注 | | `remark` | string | 备注 |
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否开启定时同步,默认开启 |
| `cron` | string | Cron 表达式,默认每 5 分钟 |
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
### dicts ### dicts
+7
View File
@@ -1,3 +1,10 @@
# 待测试 # 待测试
- 模型选择器改为共享的 shadcn 风格下拉,按模型名称显示 OpenAI、Claude、Gemini 图标,并把画布节点下方、右侧助手和配置弹窗统一到同一套组件;右侧助手的模型和模式按钮会随工具栏宽度自动在文字态和图标态之间切换,需要确认生图页、画布助手、节点配置和配置弹窗中的选中态和下拉列表显示正常。
- 画布图像设置里的尺寸输入去掉了原生数字步进箭头,需要确认宽高仍可直接输入、禁用态正常、设置值回写正常。
- 后台提示词定时同步默认改为开启且默认每 5 分钟执行一次,需要确认新环境未保存过设置时开关默认打开、Cron 默认值正确,用户手动关闭或修改 Cron 后能保持配置。
- 画布右侧助手和节点下方对话框的生图设置弹层改为紧凑图像设置面板,面板只包含质量、尺寸、宽高比和生成数量选择,模型选择保留在对话框底部工具栏;需要确认助手内设置面板不会被右侧面板裁切,节点内质量可正常选择并参与生成、尺寸可编辑、宽高比预览显示正常且为直角矩形、auto 不重复显示、张数每行 4 个且自定义输入框能显示当前值、实际生图参数正常;节点设置面板打开时会隐藏节点悬浮工具条,避免遮挡质量选择。
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。 - 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
- 提示词远程源新增 `davidwuw0811-boop/awesome-gpt-image2-prompts`,同步时会读取仓库 `prompts.json` 的结构化数据,需要确认后台同步后分类、封面图、标签和提示词内容展示正常。
+13 -1
View File
@@ -59,13 +59,18 @@
"enabled": true, "enabled": true,
"remark": "" "remark": ""
} }
] ],
"promptSync": {
"enabled": true,
"cron": "*/5 * * * *"
}
} }
``` ```
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `channels` | object[] | 模型渠道列表 | | `channels` | object[] | 模型渠道列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
`channels` 每项字段: `channels` 每项字段:
@@ -81,3 +86,10 @@
| `remark` | string | 备注 | | `remark` | string | 备注 |
后端调用模型时,会从已启用、已配置 `baseUrl``apiKey`、且 `models` 包含目标模型的渠道中选择一个。 后端调用模型时,会从已启用、已配置 `baseUrl``apiKey`、且 `models` 包含目标模型的渠道中选择一个。
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | boolean | 是否开启定时同步,默认开启 |
| `cron` | string | Cron 表达式,默认每 5 分钟 |
+1
View File
@@ -7,3 +7,4 @@
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 | | https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 | | https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
| https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 | | https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 |
| https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts | 已实现同步逻辑 |
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.48.0
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
+2
View File
@@ -83,6 +83,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+14
View File
@@ -13,6 +13,10 @@ type adminSyncRequest struct {
Category string `json:"category"` Category string `json:"category"`
} }
type adminBatchDeleteRequest struct {
IDs []string `json:"ids"`
}
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) { func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
OK(w, service.ListPromptCategories()) OK(w, service.ListPromptCategories())
} }
@@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
OK(w, true) OK(w, true)
} }
func AdminDeletePrompts(w http.ResponseWriter, r *http.Request) {
var request adminBatchDeleteRequest
_ = json.NewDecoder(r.Body).Decode(&request)
if err := service.DeletePrompts(request.IDs); err != nil {
FailError(w, err)
return
}
OK(w, true)
}
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) { func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
var request adminSyncRequest var request adminSyncRequest
_ = json.NewDecoder(r.Body).Decode(&request) _ = json.NewDecoder(r.Body).Decode(&request)
+1
View File
@@ -15,5 +15,6 @@ func main() {
if err := service.EnsureDefaultAdmin(); err != nil { if err := service.EnsureDefaultAdmin(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
service.StartPromptSyncScheduler()
log.Fatal(router.New().Run(":" + config.Cfg.Port)) log.Fatal(router.New().Run(":" + config.Cfg.Port))
} }
+8 -1
View File
@@ -38,7 +38,14 @@ type PublicSetting struct {
// PrivateSetting 私有配置。 // PrivateSetting 私有配置。
type PrivateSetting struct { type PrivateSetting struct {
Channels []ModelChannel `json:"channels"` Channels []ModelChannel `json:"channels"`
PromptSync PromptSyncSetting `json:"promptSync"`
}
// PromptSyncSetting 提示词定时同步配置。
type PromptSyncSetting struct {
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
} }
// Setting 系统配置。 // Setting 系统配置。
+1
View File
@@ -21,6 +21,7 @@ var promptCategories = []model.PromptCategory{
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true}, {Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
{Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true}, {Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true},
{Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true}, {Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true},
{Category: "davidwu-gpt-image2-prompts", Name: "awesome-gpt-image2-prompts", Description: "davidwuw0811-boop 整理的 GPT Image 2 提示词分类", GithubURL: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", Remote: true},
} }
var ( var (
+9
View File
@@ -99,6 +99,15 @@ func DeletePrompt(id string) error {
return db.Delete(&model.Prompt{}, "id = ?", id).Error return db.Delete(&model.Prompt{}, "id = ?", id).Error
} }
// DeletePrompts 批量删除提示词。
func DeletePrompts(ids []string) error {
db, err := DB()
if err != nil {
return err
}
return db.Delete(&model.Prompt{}, "id IN ?", ids).Error
}
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。 // ReplacePromptCategory 用远程同步结果替换整个提示词分类。
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error { func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
db, err := DB() db, err := DB()
+1
View File
@@ -41,6 +41,7 @@ func New() *gin.Engine {
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories)) admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts)) admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt)) admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
admin.DELETE("/prompts/:id", func(c *gin.Context) { admin.DELETE("/prompts/:id", func(c *gin.Context) {
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id")) handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
}) })
+64
View File
@@ -20,6 +20,7 @@ const (
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main" awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main" youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main" youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"
) )
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"} var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
@@ -34,6 +35,20 @@ type gptImage2Data struct {
} `json:"records"` } `json:"records"`
} }
type davidWuGptImage2Prompt struct {
ID int `json:"id"`
TitleEN string `json:"title_en"`
TitleCN string `json:"title_cn"`
Category string `json:"category"`
CategoryCN string `json:"category_cn"`
Prompt string `json:"prompt"`
Note string `json:"note"`
Author string `json:"author"`
Source string `json:"source"`
NeedsRef bool `json:"needs_ref"`
Image string `json:"image"`
}
func SyncPromptCategory(category string) ([]model.PromptCategory, error) { func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
for _, item := range repository.PromptCategories() { for _, item := range repository.PromptCategories() {
if item.Category != category { if item.Category != category {
@@ -63,6 +78,8 @@ func buildPromptCategory(category string) ([]model.Prompt, error) {
return buildYouMindGptImage2Prompts() return buildYouMindGptImage2Prompts()
case "youmind-nano-banana-pro": case "youmind-nano-banana-pro":
return buildYouMindNanoBananaProPrompts() return buildYouMindNanoBananaProPrompts()
case "davidwu-gpt-image2-prompts":
return buildDavidWuGptImage2Prompts()
} }
return nil, errors.New("未知提示词分类") return nil, errors.New("未知提示词分类")
} }
@@ -173,6 +190,31 @@ func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
} }
func buildDavidWuGptImage2Prompts() ([]model.Prompt, error) {
raw, err := fetchText(davidWuGptImage2RawBase, "prompts.json")
if err != nil {
return nil, err
}
data := []davidWuGptImage2Prompt{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, err
}
items := []model.Prompt{}
for _, item := range data {
title := strings.TrimSpace(item.TitleCN)
if title == "" {
title = strings.TrimSpace(item.TitleEN)
}
prompt := strings.TrimSpace(item.Prompt)
if title == "" || prompt == "" {
continue
}
image := absoluteImage(davidWuGptImage2RawBase, item.Image)
items = append(items, model.Prompt{ID: "davidwu-gpt-image2-prompts-" + leftPad(item.ID), Title: title, CoverURL: image, Prompt: prompt, Tags: davidWuGptImage2Tags(item), Preview: davidWuGptImage2Preview(item, image)})
}
return items, nil
}
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) { func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
markdown, err := fetchText(baseURL, "README_zh.md") markdown, err := fetchText(baseURL, "README_zh.md")
if err != nil { if err != nil {
@@ -234,6 +276,28 @@ func youMindTags(title, modelTag string) []string {
return tags return tags
} }
func davidWuGptImage2Tags(item davidWuGptImage2Prompt) []string {
tags := splitTags(strings.Join([]string{item.CategoryCN, item.Category, item.Author, item.Source}, "/"), `/`)
if item.NeedsRef {
tags = append(tags, "需要参考图")
}
return tags
}
func davidWuGptImage2Preview(item davidWuGptImage2Prompt, image string) string {
lines := []string{}
if item.TitleEN != "" {
lines = append(lines, item.TitleEN)
}
if item.Note != "" {
lines = append(lines, item.Note)
}
if image != "" {
lines = append(lines, "![]("+image+")")
}
return strings.Join(lines, "\n\n")
}
func splitTags(value string, pattern string) []string { func splitTags(value string, pattern string) []string {
tags := []string{} tags := []string{}
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) { for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"log"
"sync"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
"github.com/robfig/cron/v3"
)
const defaultPromptSyncCron = "*/5 * * * *"
var (
promptSyncCron *cron.Cron
promptSyncOnce sync.Once
promptSyncMu sync.Mutex
)
func StartPromptSyncScheduler() {
promptSyncOnce.Do(func() {
promptSyncCron = cron.New()
promptSyncCron.Start()
})
RefreshPromptSyncScheduler()
}
func RefreshPromptSyncScheduler() {
promptSyncMu.Lock()
defer promptSyncMu.Unlock()
if promptSyncCron == nil {
return
}
for _, entry := range promptSyncCron.Entries() {
promptSyncCron.Remove(entry.ID)
}
settings, err := repository.GetSettings()
if err != nil {
log.Printf("load prompt sync setting failed err=%v", err)
return
}
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
if setting.Enabled == nil || !*setting.Enabled {
return
}
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err)
}
}
func SyncRemotePromptCategories() {
for _, category := range repository.PromptCategories() {
if !category.Remote {
continue
}
log.Printf("scheduled prompt sync start category=%s", category.Category)
if _, err := SyncPromptCategory(category.Category); err != nil {
log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err)
continue
}
log.Printf("scheduled prompt sync done category=%s", category.Category)
}
}
func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting {
if setting.Cron == "" {
setting.Cron = defaultPromptSyncCron
}
if setting.Enabled == nil {
enabled := true
setting.Enabled = &enabled
}
return setting
}
+7
View File
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
return repository.DeletePrompt(id) return repository.DeletePrompt(id)
} }
func DeletePrompts(ids []string) error {
if len(ids) == 0 {
return nil
}
return repository.DeletePrompts(ids)
}
func promptCategoryCodes(items []model.PromptCategory) []string { func promptCategoryCodes(items []model.PromptCategory) []string {
codes := []string{} codes := []string{}
for _, item := range items { for _, item := range items {
+4
View File
@@ -32,6 +32,9 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
settings = normalizeSettings(settings) settings = normalizeSettings(settings)
keepPrivateAPIKeys(&settings, normalizeSettings(saved)) keepPrivateAPIKeys(&settings, normalizeSettings(saved))
result, err := repository.SaveSettings(settings, now()) result, err := repository.SaveSettings(settings, now())
if err == nil {
RefreshPromptSyncScheduler()
}
return hidePrivateAPIKeys(result), err return hidePrivateAPIKeys(result), err
} }
@@ -72,6 +75,7 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
if setting.Channels == nil { if setting.Channels == nil {
setting.Channels = []model.ModelChannel{} setting.Channels = []model.ModelChannel{}
} }
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
for i := range setting.Channels { for i := range setting.Channels {
if setting.Channels[i].Protocol == "" { if setting.Channels[i].Protocol == "" {
setting.Channels[i].Protocol = "openai" setting.Channels[i].Protocol = "openai"
+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=="],
+25 -25
View File
@@ -1,27 +1,27 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova", "style": "radix-nova",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false, "rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default", "menuColor": "default",
"menuAccent": "subtle", "menuAccent": "subtle",
"registries": { "registries": {
"@magicui": "https://magicui.design/r/{name}" "@magicui": "https://magicui.design/r/{name}"
} }
} }
+12 -12
View File
@@ -10,17 +10,17 @@ const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim()
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
export default function nextConfig(phase: string): NextConfig { export default function nextConfig(phase: string): NextConfig {
const isDev = phase === PHASE_DEVELOPMENT_SERVER; const isDev = phase === PHASE_DEVELOPMENT_SERVER;
const releases = parseChangelog(localChangelog); const releases = parseChangelog(localChangelog);
return { return {
allowedDevOrigins: isDev ? ["*.*.*.*"] : [], allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
env: { env: {
NEXT_PUBLIC_APP_VERSION: localVersion, NEXT_PUBLIC_APP_VERSION: localVersion,
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases), NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
}, },
}; };
} }
+45 -42
View File
@@ -1,44 +1,47 @@
{ {
"name": "infinite-canvas", "name": "infinite-canvas",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"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 .",
"dependencies": { "format:check": "prettier --check ."
"@ant-design/icons": "^6.1.1", },
"@ant-design/nextjs-registry": "^1.3.0", "dependencies": {
"@ant-design/pro-components": "3.0.0-beta.3", "@ant-design/icons": "^6.1.1",
"@codemirror/lang-json": "^6.0.2", "@ant-design/nextjs-registry": "^1.3.0",
"@tanstack/react-query": "^5.100.9", "@ant-design/pro-components": "3.0.0-beta.3",
"@uiw/react-codemirror": "^4.25.9", "@codemirror/lang-json": "^6.0.2",
"antd": "^6.4.2", "@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0", "@uiw/react-codemirror": "^4.25.9",
"class-variance-authority": "^0.7.1", "antd": "^6.4.2",
"clsx": "^2.1.1", "axios": "^1.16.0",
"copy-to-clipboard": "^4.0.2", "class-variance-authority": "^0.7.1",
"localforage": "^1.10.0", "clsx": "^2.1.1",
"lucide-react": "^1.16.0", "copy-to-clipboard": "^4.0.2",
"motion": "^12.38.0", "localforage": "^1.10.0",
"nanoid": "^5.1.11", "lucide-react": "^1.16.0",
"next": "16.2.3", "motion": "^12.38.0",
"radix-ui": "^1.4.3", "nanoid": "^5.1.11",
"react": "19.2.5", "next": "16.2.3",
"react-dom": "19.2.5", "radix-ui": "^1.4.3",
"shadcn": "^4.7.0", "react": "19.2.5",
"tailwind-merge": "^3.6.0", "react-dom": "19.2.5",
"tailwindcss": "^4", "shadcn": "^4.7.0",
"tw-animate-css": "^1.4.0", "tailwind-merge": "^3.6.0",
"zustand": "^5.0.12" "tailwindcss": "^4",
}, "tw-animate-css": "^1.4.0",
"devDependencies": { "zustand": "^5.0.12"
"@tailwindcss/postcss": "^4", },
"@types/node": "^20", "devDependencies": {
"@types/react": "19.1.12", "@tailwindcss/postcss": "^4",
"@types/react-dom": "19.1.9", "@types/node": "^20",
"typescript": "^5" "@types/react": "19.1.12",
} "@types/react-dom": "19.1.9",
"prettier": "^3.8.3",
"typescript": "^5"
}
} }
+3 -3
View File
@@ -1,7 +1,7 @@
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
export default config; export default config;
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+235 -132
View File
@@ -12,147 +12,250 @@ import { useAdminAssets } from "./use-admin-assets";
type AssetFormValues = Partial<AdminAsset> & { tagText?: string }; type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
const typeOptions = [ const typeOptions = [
{ label: "全部类型", value: "" }, { label: "全部类型", value: "" },
{ label: "文本", value: "text" }, { label: "文本", value: "text" },
{ label: "图片", value: "image" }, { label: "图片", value: "image" },
]; ];
const editTypeOptions = typeOptions.slice(1); const editTypeOptions = typeOptions.slice(1);
export default function AdminAssetsPage() { export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const copyText = useCopyText(); const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>(); const [form] = Form.useForm<AssetFormValues>();
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null); const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null); const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null); const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
const formType = Form.useWatch("type", form) || editingAsset?.type || "text"; const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
const tagOptions = tags.map((item) => ({ label: item, value: item })); const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => { useEffect(() => {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]); }, [editingAsset, form]);
const saveAsset = async () => { const saveAsset = async () => {
const value = await form.validateFields(); const value = await form.validateFields();
const nextType = value.type || "text"; const nextType = value.type || "text";
await saveAdminAsset({ await saveAdminAsset({
...editingAsset, ...editingAsset,
...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(",")
setEditingAsset(null); .map((item) => item.trim())
}; .filter(Boolean),
});
setEditingAsset(null);
};
const columns: ProColumns<AdminAsset>[] = [ const columns: ProColumns<AdminAsset>[] = [
{ {
title: "封面", title: "封面",
dataIndex: "coverUrl", dataIndex: "coverUrl",
width: 88, width: 88,
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />, render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
}, },
{ {
title: "标题", 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}
title: "类型", </Typography.Link>
dataIndex: "type", ),
width: 84, },
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>, {
}, title: "类型",
{ dataIndex: "type",
title: "标签", width: 84,
dataIndex: "tags", render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
width: 180, },
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>, {
}, title: "标签",
{ dataIndex: "tags",
title: "分类", width: 180,
dataIndex: "category", render: (_, item) => (
width: 120, <Space size={[4, 4]} wrap>
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>, {(item.tags || []).slice(0, 3).map((tag) => (
}, <Tag key={tag}>{tag}</Tag>
{ ))}
title: "操作", </Space>
key: "actions", ),
width: 112, },
align: "right", {
render: (_, item) => ( title: "分类",
<Space size={4}> dataIndex: "category",
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip> width: 120,
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip> render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip> },
</Space> {
), title: "操作",
}, key: "actions",
]; width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
</Tooltip>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} />
</Tooltip>
</Space>
),
},
];
return ( return (
<main style={{ padding: 24 }}> <main style={{ padding: 24 }}>
<Flex vertical gap={16}> <Flex vertical gap={16}>
<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>
</Row> </Col>
</Form> <Col flex="180px">
</Card> <Form.Item label="类型">
<ProTable<AdminAsset> <Select value={kind} onChange={changeKind} options={typeOptions} />
rowKey="id" </Form.Item>
columns={columns} </Col>
dataSource={assets} <Col flex="220px">
loading={isLoading} <Form.Item label="标签">
search={false} <Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
defaultSize="middle" </Form.Item>
tableLayout="fixed" </Col>
cardProps={{ variant: "borderless" }} <Col flex="none">
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>} <Form.Item>
options={{ density: true, setting: true, reload: () => void refreshAssets() }} <Space>
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}></Button>]} <Button onClick={resetFilters}></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) }} <Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>
/>
</Flex> </Button>
</Space>
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> </Form.Item>
<Form form={form} layout="vertical" requiredMark={false}> </Col>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item> </Row>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item> </Form>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item> </Card>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item> <ProTable<AdminAsset>
<Form.Item name="category" label="分类"><Input /></Form.Item> rowKey="id"
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item> columns={columns}
{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>} dataSource={assets}
</Form> loading={isLoading}
</Modal> search={false}
defaultSize="middle"
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}> tableLayout="fixed"
{detailAsset ? ( cardProps={{ variant: "borderless" }}
<Flex vertical gap={14}> headerTitle={
<Flex gap={14} align="start"> <Space>
<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" /> <Typography.Text strong></Typography.Text>
<Flex vertical gap={8} style={{ minWidth: 0 }}> <Tag>{total} </Tag>
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title> </Space>
<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> options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
}}
/>
</Flex> </Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
</Flex>
) : null}
</Modal>
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
{deletingAsset?.title} <Form form={form} layout="vertical" requiredMark={false}>
</Modal> <Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
</main> <Select options={editTypeOptions} />
); </Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Input />
</Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔">
<Input />
</Form.Item>
<Form.Item name="category" label="分类">
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
{formType === "image" ? (
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
<Input />
</Form.Item>
) : (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={6} />
</Form.Item>
)}
</Form>
</Modal>
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}>
{detailAsset ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{detailAsset.title}
</Typography.Title>
<Space wrap>
<Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>
{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}
{(detailAsset.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</Flex>
</Flex>
{detailAsset.description ? (
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
{detailAsset.description}
</Typography.Paragraph>
) : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
</Button>
</Flex>
) : null}
</Modal>
<Modal
title="删除素材"
open={Boolean(deletingAsset)}
onCancel={() => setDeletingAsset(null)}
onOk={async () => {
if (!deletingAsset) return;
await deleteAsset(deletingAsset.id);
setDeletingAsset(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
{deletingAsset?.title}
</Modal>
</main>
);
} }
@@ -10,83 +10,83 @@ import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10; const defaultPageSize = 10;
export function useAdminAssets() { export function useAdminAssets() {
const { message } = App.useApp(); const { message } = App.useApp();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession); const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState("");
const [tag, setTag] = useState<string[]>([]); const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize); const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({ const query = useQuery({
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize], queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }), queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
enabled: Boolean(token), enabled: Boolean(token),
retry: false, retry: false,
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset), mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
onSuccess: async (_, asset) => { onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增"); message.success(asset.id ? "素材已保存" : "素材已新增");
}, },
onError: (error) => { onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败"); message.error(error instanceof Error ? error.message : "保存失败");
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id), mutationFn: (id: string) => deleteAdminAsset(token, id),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除"); message.success("素材已删除");
}, },
onError: (error) => { onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败"); message.error(error instanceof Error ? error.message : "删除失败");
}, },
}); });
useEffect(() => { useEffect(() => {
if (query.isError) { if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败"; const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
message.error(errorMessage); message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
} }
}, [clearSession, message, query.error, query.isError]); }, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => { const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, type, tag, page, pageSize, ...next }; const queryState = { keyword, type, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword); setKeyword(queryState.keyword);
setType(queryState.type); setType(queryState.type);
setTag(queryState.tag); setTag(queryState.tag);
setPage(queryState.page); setPage(queryState.page);
setPageSize(queryState.pageSize); setPageSize(queryState.pageSize);
}; };
const data = query.data; const data = query.data;
return { return {
assets: data?.items || [], assets: data?.items || [],
tags: data?.tags || [], tags: data?.tags || [],
keyword, keyword,
kind: type, kind: type,
tag, tag,
page, page,
pageSize, pageSize,
total: data?.total || 0, total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
searchAssets: (value = keyword) => updateFilters({ keyword: value }), searchAssets: (value = keyword) => updateFilters({ keyword: value }),
changeKind: (value: string) => updateFilters({ type: value, tag: [] }), changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }), changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }), changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }), changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }), resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshAssets: () => query.refetch(), refreshAssets: () => query.refetch(),
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset), saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
deleteAsset: (id: string) => deleteMutation.mutateAsync(id), deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
}; };
} }
+75 -57
View File
@@ -12,68 +12,86 @@ import { adminLayoutStyle } from "@/lib/app-theme";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
const adminMenus = [ const adminMenus = [
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" }, { key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" }, { key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" }, { key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
]; ];
export default function AdminLayout({ children }: { children: ReactNode }) { export default function AdminLayout({ children }: { children: ReactNode }) {
const { token: antToken } = theme.useToken(); const { token: antToken } = theme.useToken();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady); const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession); const logout = useUserStore((state) => state.clearSession);
const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
useEffect(() => { useEffect(() => {
if (!isReady) return; if (!isReady) return;
if (!token) { if (!token) {
router.replace("/login?redirect=/admin"); router.replace("/login?redirect=/admin");
return; return;
} }
if (user?.role !== "admin") { if (user?.role !== "admin") {
router.replace("/"); router.replace("/");
} }
}, [isReady, router, token, user?.role]); }, [isReady, router, token, user?.role]);
if (!isReady || !token || user?.role !== "admin") {
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}>
<span />
</div>
);
}
if (!isReady || !token || user?.role !== "admin") {
return ( return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}> <Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
<span /> <Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
</div> <Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
</Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({
...item,
label: (
<Link href={item.key} style={{ color: "inherit" }}>
{item.label}
</Link>
),
style: adminLayoutStyle.menuItem,
}))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
</Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
<Layout.Header
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{pageTitle}
</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions showConfig={false} />
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
); );
}
return (
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}></Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer"></Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>退</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}>
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions showConfig={false} />
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default function AdminPage() { export default function AdminPage() {
redirect("/admin/assets"); redirect("/admin/assets");
} }
+316 -130
View File
@@ -10,142 +10,328 @@ 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 } = useAdminPrompts(); const {
const copyText = useCopyText(); categories,
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>(); prompts,
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null); tags,
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null); keyword,
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null); category,
const [isSyncOpen, setIsSyncOpen] = useState(false); tag,
const defaultCategory = categories[0]?.category || ""; page,
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category; pageSize,
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))]; total,
const tagOptions = tags.map((item) => ({ label: item, value: item })); isLoading,
isSyncing,
searchPrompts,
changeCategory,
changeTag,
changePage,
changePageSize,
resetFilters,
refreshPrompts,
syncCategory,
savePrompt: saveAdminPrompt,
deletePrompt,
deletePrompts,
} = useAdminPrompts();
const copyText = useCopyText();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
const [isSyncOpen, setIsSyncOpen] = useState(false);
const defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => { useEffect(() => {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]); }, [editingPrompt, form]);
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({
setEditingPrompt(null); ...editingPrompt,
}; ...value,
category: value.category || defaultCategory,
tags: (value.tagText || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
});
setEditingPrompt(null);
};
const columns: ProColumns<Prompt>[] = [ const batchDeletePrompts = async () => {
{ await deletePrompts(selectedPromptIds);
title: "封面", setSelectedPromptIds([]);
dataIndex: "coverUrl", setIsBatchDeleteOpen(false);
width: 88, };
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
},
{
title: "分类",
dataIndex: "category",
width: 150,
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
</Space>
),
},
];
return ( const columns: ProColumns<Prompt>[] = [
<main style={{ padding: 24 }}> {
<Flex vertical gap={16}> title: "封面",
<Card variant="borderless"> dataIndex: "coverUrl",
<Form layout="vertical"> width: 88,
<Row gutter={16} align="bottom"> render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col> },
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col> {
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col> title: "标题",
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}></Button></Space></Form.Item></Col> dataIndex: "title",
</Row> width: 260,
</Form> render: (_, item) => (
</Card> <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
<ProTable<Prompt> {item.title}
rowKey="id" </Typography.Link>
columns={columns} ),
dataSource={prompts} },
loading={isLoading} {
search={false} title: "分类",
defaultSize="middle" dataIndex: "category",
tableLayout="fixed" width: 150,
cardProps={{ variant: "borderless" }} render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>} },
options={{ density: true, setting: true, reload: () => void refreshPrompts() }} {
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}></Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}></Button>]} title: "标签",
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }} dataIndex: "tags",
/> width: 180,
</Flex> render: (_, item) => (
<Space size={[4, 4]} wrap>
{(item.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
),
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
</Tooltip>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} />
</Tooltip>
</Space>
),
},
];
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden> return (
<Form form={form} layout="vertical" requiredMark={false}> <main style={{ padding: 24 }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item> <Flex vertical gap={16}>
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item> <Card variant="borderless">
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item> <Form layout="vertical">
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item> <Row gutter={16} align="bottom">
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item> <Col flex="360px">
</Form> <Form.Item label="关键词">
</Modal> <Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
</Form.Item>
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}> </Col>
{detailPrompt ? ( <Col flex="220px">
<Flex vertical gap={14}> <Form.Item label="分组">
<Flex gap={14} align="start"> <Select value={category} onChange={changeCategory} options={categoryOptions} />
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" /> </Form.Item>
<Flex vertical gap={8} style={{ minWidth: 0 }}> </Col>
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title> <Col flex="220px">
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space> <Form.Item label="标签">
</Flex> <Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
</Form.Item>
</Col>
<Col flex="none">
<Form.Item>
<Space>
<Button onClick={resetFilters}></Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<Prompt>
rowKey="id"
columns={columns}
dataSource={prompts}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
toolBarRender={() => [
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
</Button>,
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
</Button>,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
}}
/>
</Flex> </Flex>
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}></Button>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}> <Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
<Table <Form form={form} layout="vertical" requiredMark={false}>
rowKey="category" <Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
dataSource={categories.filter((item) => item.remote)} <Input />
pagination={false} </Form.Item>
columns={[ <Form.Item name="category" label="分类">
{ 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> }, <Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
{ 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> }, </Form.Item>
]} <Form.Item name="coverUrl" label="封面 URL">
/> <Input />
</Modal> </Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔">
<Input />
</Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
<Input.TextArea rows={6} />
</Form.Item>
</Form>
</Modal>
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> <Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}>
{deletingPrompt?.title} {detailPrompt ? (
</Modal> <Flex vertical gap={14}>
</main> <Flex gap={14} align="start">
); <Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{detailPrompt.title}
</Typography.Title>
<Space wrap>
<Tag>{categoryName(detailPrompt.category)}</Tag>
{(detailPrompt.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</Flex>
</Flex>
{detailPrompt.preview ? (
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
{detailPrompt.preview}
</Typography.Paragraph>
) : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
</Button>
{detailPrompt.githubUrl ? (
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
</Button>
) : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal
title="同步远程提示词源"
open={isSyncOpen}
width={640}
onCancel={() => !isSyncing && setIsSyncOpen(false)}
mask={{ closable: !isSyncing }}
footer={
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
</Button>
}
>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
pagination={false}
columns={[
{
title: "远程源",
dataIndex: "name",
render: (_, item) => (
<Flex align="center" gap={8}>
{item.name}
{item.githubUrl ? (
<Typography.Link href={item.githubUrl} target="_blank">
<ExportOutlined />
</Typography.Link>
) : null}
</Flex>
),
},
{
title: "",
key: "sync",
width: 96,
align: "right",
render: (_, item) => (
<Button
type="primary"
loading={isSyncing}
onClick={async () => {
try {
await syncCategory(item.category);
setIsSyncOpen(false);
} catch {}
}}
>
</Button>
),
},
]}
/>
</Modal>
<Modal
title="删除提示词"
open={Boolean(deletingPrompt)}
onCancel={() => setDeletingPrompt(null)}
onOk={async () => {
if (!deletingPrompt) return;
await deletePrompt(deletingPrompt.id);
setDeletingPrompt(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
{deletingPrompt?.title}
</Modal>
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedPromptIds.length}
</Modal>
</main>
);
} }
@@ -4,117 +4,130 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd"; import { App } from "antd";
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin"; import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
import type { Prompt } from "@/services/api/prompts"; import type { Prompt } from "@/services/api/prompts";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10; const defaultPageSize = 10;
export function useAdminPrompts() { export function useAdminPrompts() {
const { message } = App.useApp(); const { message } = App.useApp();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession); const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
const [tag, setTag] = useState<string[]>([]); const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize); const [pageSize, setPageSize] = useState(defaultPageSize);
const categoriesQuery = useQuery({ const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token], queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token), queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token), enabled: Boolean(token),
retry: false, retry: false,
}); });
const promptsQuery = useQuery({ const promptsQuery = useQuery({
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize], queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }), queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
enabled: Boolean(token), enabled: Boolean(token),
retry: false, retry: false,
}); });
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(token, category), mutationFn: (category: string) => syncAdminPromptCategory(token, category),
onSuccess: async (categories) => { onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories); queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步"); message.success("远程提示词源已同步");
}, },
onError: (error) => { onError: (error) => {
message.error(error instanceof Error ? error.message : "同步失败"); message.error(error instanceof Error ? error.message : "同步失败");
}, },
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt), mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
onSuccess: async (_, prompt) => { onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success(prompt.id ? "提示词已保存" : "提示词已新增"); message.success(prompt.id ? "提示词已保存" : "提示词已新增");
}, },
onError: (error) => { onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败"); message.error(error instanceof Error ? error.message : "保存失败");
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id), mutationFn: (id: string) => deleteAdminPrompt(token, id),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("提示词已删除"); message.success("提示词已删除");
}, },
onError: (error) => { onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败"); message.error(error instanceof Error ? error.message : "删除失败");
}, },
}); });
useEffect(() => { const batchDeleteMutation = useMutation({
const error = categoriesQuery.error || promptsQuery.error; mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
if (!error) return; onSuccess: async () => {
const errorMessage = error instanceof Error ? error.message : "读取提示词失败"; await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
message.error(errorMessage); await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); message.success("提示词已批量删除");
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]); },
onError: (error) => {
message.error(error instanceof Error ? error.message : "批量删除失败");
},
});
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => { useEffect(() => {
const queryState = { keyword, category, tag, page, pageSize, ...next }; const error = categoriesQuery.error || promptsQuery.error;
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; if (!error) return;
setKeyword(queryState.keyword); const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
setCategory(queryState.category); message.error(errorMessage);
setTag(queryState.tag); if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
setPage(queryState.page); }, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
setPageSize(queryState.pageSize);
};
const data = promptsQuery.data; const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, category, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setCategory(queryState.category);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
return { const data = promptsQuery.data;
categories: categoriesQuery.data || [],
prompts: data?.items || [], return {
tags: data?.tags || [], categories: categoriesQuery.data || [],
keyword, prompts: data?.items || [],
category, tags: data?.tags || [],
tag, keyword,
page, category,
pageSize, tag,
total: data?.total || 0, page,
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending, pageSize,
isSyncing: syncMutation.isPending, total: data?.total || 0,
syncCategory: (category: string) => syncMutation.mutateAsync(category), isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
searchPrompts: (value = keyword) => updateFilters({ keyword: value }), isSyncing: syncMutation.isPending,
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }), syncCategory: (category: string) => syncMutation.mutateAsync(category),
changeTag: (value: string[]) => updateFilters({ tag: value }), searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
changePage: (value: number) => updateFilters({ page: value }), changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
changePageSize: (value: number) => updateFilters({ pageSize: value }), changeTag: (value: string[]) => updateFilters({ tag: value }),
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }), changePage: (value: number) => updateFilters({ page: value }),
refreshPrompts: async () => { changePageSize: (value: number) => updateFilters({ pageSize: value }),
await categoriesQuery.refetch(); resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
await promptsQuery.refetch(); refreshPrompts: async () => {
}, await categoriesQuery.refetch();
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt), await promptsQuery.refetch();
deletePrompt: (id: string) => deleteMutation.mutateAsync(id), },
}; savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
};
} }
File diff suppressed because it is too large Load Diff
+255 -181
View File
@@ -15,200 +15,274 @@ import { uploadImage } from "@/services/image-storage";
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
export default function AssetLibraryPage() { export default function AssetLibraryPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const copyText = useCopyText(); const copyText = useCopyText();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState(""); const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null); const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset); const addAsset = useAssetStore((state) => state.addAsset);
const query = useQuery({ const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page], queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }), queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false, retry: false,
}); });
useEffect(() => { useEffect(() => {
if (query.isError) { if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败"); message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
);
} }
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
if (!isReady) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<Spin /> <main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
</div> <div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input
size="large"
className="w-full"
prefix={<Search className="size-4 text-stone-400" />}
value={keyword}
placeholder="按标题查询"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
/>
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag
key={item.value || "all"}
checked={selectedType === item.value}
className={cn("prompt-filter-tag", selectedType === item.value && "is-active")}
onChange={() => {
setPage(1);
setSelectedType(item.value);
}}
>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag
checked={selectedTags.length === 0}
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
onChange={() => {
setPage(1);
setSelectedTags([]);
}}
>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
onChange={() => {
setPage(1);
toggleTag(tag);
}}
>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? (
<Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" />
) : (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>
)}
<div>
<Typography.Title level={4} className="!mb-2">
{selectedAsset.title}
</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs">
</Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
</Button>
) : null}
{selectedAsset.type === "image" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
</Button>
) : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
</Button>
</div>
</div>
) : null}
</Drawer>
</div>
); );
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}></Button> : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
} }
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) { 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
<button type="button" className="block w-full text-left" onClick={onOpen}> hoverable
<div className="p-4"> className="overflow-hidden"
<div className="flex items-start justify-between gap-3"> styles={{ body: { padding: 0 } }}
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2> cover={
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag> <button type="button" className="block w-full text-left" onClick={onOpen}>
</div> {cover ? (
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5"> <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
{asset.type === "text" ? asset.content : asset.url} ) : (
</Typography.Paragraph> <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>
<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>)} </button>
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null} }
</div> >
</div> <button type="button" className="block w-full text-left" onClick={onOpen}>
</button> <div className="p-4">
<div className="flex items-center gap-2 px-4 pb-4"> <div className="flex items-start justify-between gap-3">
<Button size="small" onClick={onOpen}></Button> <h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}></Button> <Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div> </div>
</Card> <Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
); {asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
</Button>
</div>
</Card>
);
} }
async function remoteImageToDataUrl(url: string) { async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" }); const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob; const blob = response.data as Blob;
return await blobToDataUrl(blob); return await blobToDataUrl(blob);
} }
function blobToDataUrl(blob: Blob) { function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "")); reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败")); reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}); });
} }
+436 -326
View File
@@ -11,373 +11,483 @@ import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
type AssetFormValues = { type AssetFormValues = {
kind: AssetKind; kind: AssetKind;
title: string; title: string;
coverUrl: string; coverUrl: string;
tags: string[]; tags: string[];
source?: string; source?: string;
note?: string; note?: string;
content?: string; content?: string;
}; };
type ImageDraft = ImageAsset["data"] | null; type ImageDraft = ImageAsset["data"] | null;
const kindOptions = [ const kindOptions = [
{ label: "全部", value: "all" }, { label: "全部", value: "all" },
{ label: "文本", value: "text" }, { label: "文本", value: "text" },
{ label: "图片", value: "image" }, { label: "图片", value: "image" },
]; ];
export default function AssetsPage() { export default function AssetsPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const copyText = useCopyText(); const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>(); const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null); const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets); const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset); const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset); const updateAsset = useAssetStore((state) => state.updateAsset);
const removeAsset = useAssetStore((state) => state.removeAsset); const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all"); const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null); const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [isAssetOpen, setIsAssetOpen] = useState(false); const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null); const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null); const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
const [formKind, setFormKind] = useState<AssetKind>("text"); const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null); const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
const coverUrl = Form.useWatch("coverUrl", form) || ""; const coverUrl = Form.useWatch("coverUrl", form) || "";
const title = Form.useWatch("title", form) || ""; const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || []; const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || ""; const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]); const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]);
const filteredAssets = useMemo(() => { const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase(); const query = keyword.trim().toLowerCase();
return validAssets.filter((asset) => { return validAssets.filter((asset) => {
if (kindFilter !== "all" && asset.kind !== kindFilter) return false; if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
if (!query) return true; if (!query) return true;
return assetSearchText(asset).includes(query); return assetSearchText(asset).includes(query);
}); });
}, [validAssets, keyword, kindFilter]); }, [validAssets, keyword, kindFilter]);
const visibleAssets = useMemo(() => { const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return filteredAssets.slice(start, start + pageSize); return filteredAssets.slice(start, start + pageSize);
}, [filteredAssets, page, pageSize]); }, [filteredAssets, page, pageSize]);
useEffect(() => { useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize)); const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
setPage((value) => Math.min(value, maxPage)); setPage((value) => Math.min(value, maxPage));
}, [filteredAssets.length, pageSize]); }, [filteredAssets.length, pageSize]);
const openCreate = () => { const openCreate = () => {
setEditingAsset(null); setEditingAsset(null);
setImageDraft(null); setImageDraft(null);
setFormKind("text"); setFormKind("text");
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
setIsAssetOpen(true); setIsAssetOpen(true);
};
const openEdit = (asset: Asset) => {
setEditingAsset(asset);
setFormKind(asset.kind);
setImageDraft(asset.kind === "image" ? asset.data : null);
form.setFieldsValue({
kind: asset.kind,
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags || [],
source: asset.source,
note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
});
setIsAssetOpen(true);
};
const saveAsset = async () => {
const values = await form.validateFields();
const base = {
title: values.title.trim(),
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
metadata: editingAsset?.metadata || { source: "manual" },
}; };
if (values.kind === "text") { const openEdit = (asset: Asset) => {
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } }; setEditingAsset(asset);
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); setFormKind(asset.kind);
} else { setImageDraft(asset.kind === "image" ? asset.data : null);
if (!imageDraft) { form.setFieldsValue({
message.error("请选择图片文件"); kind: asset.kind,
return; title: asset.title,
} coverUrl: asset.coverUrl,
const asset = { ...base, kind: "image" as const, data: imageDraft }; tags: asset.tags || [],
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); source: asset.source,
} note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
});
setIsAssetOpen(true);
};
message.success(editingAsset ? "素材已更新" : "素材已保存"); const saveAsset = async () => {
setIsAssetOpen(false); const values = await form.validateFields();
}; const base = {
title: values.title.trim(),
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
metadata: editingAsset?.metadata || { source: "manual" },
};
const readCoverFile = async (file?: File) => { if (values.kind === "text") {
if (!file) return; const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
const dataUrl = await readFileAsDataUrl(file); editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
form.setFieldValue("coverUrl", dataUrl); } else {
}; if (!imageDraft) {
message.error("请选择图片文件");
return;
}
const asset = { ...base, kind: "image" as const, data: imageDraft };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
}
const readImageFile = async (file?: File) => { message.success(editingAsset ? "素材已更新" : "素材已保存");
if (!file || !file.type.startsWith("image/")) return; setIsAssetOpen(false);
const image = await uploadImage(file); };
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
setImageDraft(draft);
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
};
const copyAssetText = async (asset: Asset) => { const readCoverFile = async (file?: File) => {
if (asset.kind !== "text") return; if (!file) return;
copyText(asset.data.content, "文本已复制"); const dataUrl = await readFileAsDataUrl(file);
}; form.setFieldValue("coverUrl", dataUrl);
};
const downloadImage = (asset: Asset) => { const readImageFile = async (file?: File) => {
if (asset.kind !== "image") return; if (!file || !file.type.startsWith("image/")) return;
const link = document.createElement("a"); const image = await uploadImage(file);
link.href = asset.data.dataUrl; const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`; setImageDraft(draft);
link.click(); if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
}; if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
};
const confirmDelete = () => { const copyAssetText = async (asset: Asset) => {
if (!deletingAsset) return; if (asset.kind !== "text") return;
removeAsset(deletingAsset.id); copyText(asset.data.content, "文本已复制");
message.success("素材已删除"); };
setDeletingAsset(null);
};
return ( const downloadImage = (asset: Asset) => {
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100"> if (asset.kind !== "image") return;
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]"> const link = document.createElement("a");
<div className="pb-8"> link.href = asset.data.dataUrl;
<div className="mx-auto max-w-5xl text-center"> link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1> link.click();
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p> };
</div>
<div className="mx-auto mt-8 w-full max-w-2xl"> const confirmDelete = () => {
<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); }} /> if (!deletingAsset) return;
</div> removeAsset(deletingAsset.id);
message.success("素材已删除");
setDeletingAsset(null);
};
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left"> return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center"> <main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div> <div className="pb-8">
<div className="flex flex-wrap gap-2"> <div className="mx-auto max-w-5xl text-center">
{kindOptions.map((option) => ( <h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<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"); }}> <p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p>
{option.label} </div>
</Tag.CheckableTag>
))} <div className="mx-auto mt-8 w-full max-w-2xl">
<Input.Search
className="w-full"
size="large"
allowClear
prefix={<Search className="size-4 text-stone-400" />}
value={keyword}
placeholder="搜索标题、内容、标签或来源"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
onSearch={(value) => {
setPage(1);
setKeyword(value);
}}
/>
</div>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{kindOptions.map((option) => (
<Tag.CheckableTag
key={option.value}
checked={kindFilter === option.value}
className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(option.value as AssetKind | "all");
}}
>
{option.label}
</Tag.CheckableTag>
))}
</div>
</div>
<button
type="button"
className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300"
onClick={openCreate}
>
</button>
</div>
</div>
</div> </div>
</div>
<button type="button" className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300" onClick={openCreate}>
</button>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5"> <div className="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} </div>
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyAssetText}
onDownload={downloadImage}
onDelete={() => setDeletingAsset(asset)}
/>
))}
</div>
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null} {!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center"> <div className="flex justify-center">
<Pagination <Pagination
current={page} current={page}
pageSize={pageSize} pageSize={pageSize}
total={filteredAssets.length} total={filteredAssets.length}
showSizeChanger showSizeChanger
pageSizeOptions={[10, 20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextPageSize) => { onChange={(nextPage, nextPageSize) => {
setPage(nextPage); setPage(nextPage);
setPageSize(nextPageSize); setPageSize(nextPageSize);
}} }}
/> />
</div> </div>
</div>
</main>
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型">
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}></Button>
</Space.Compact>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 提示词库" />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="可选" />
</Form.Item>
</div>
{formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
</Form.Item>
) : (
<Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}></Button>
{imageDraft ? <Typography.Text type="secondary" className="ml-3 text-xs">{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}</Typography.Text> : <Typography.Text type="secondary" className="ml-3 text-xs"></Typography.Text>}
</div> </div>
</Form.Item> </main>
)}
</Form> <Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950"> <div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Typography.Text strong></Typography.Text> <Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800"> <Form.Item name="kind" label="类型">
{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>} <Select
<div className="p-4"> options={[
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text> { label: "文本", value: "text" },
<div className="mt-2 flex flex-wrap gap-1.5"> { label: "图片", value: "image" },
{tags.length ? tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>) : <Tag className="m-0"></Tag>} ]}
onChange={(value) => setFormKind(value)}
/>
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 提示词库" />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="可选" />
</Form.Item>
</div>
{formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
</Form.Item>
) : (
<Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
</Button>
{imageDraft ? (
<Typography.Text type="secondary" className="ml-3 text-xs">
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}
</Typography.Text>
) : (
<Typography.Text type="secondary" className="ml-3 text-xs">
</Typography.Text>
)}
</div>
</Form.Item>
)}
</Form>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
<Typography.Text strong></Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
{coverUrl || imageDraft?.dataUrl ? (
<img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
)}
<div className="p-4">
<Typography.Text strong ellipsis className="block">
{title || "未命名素材"}
</Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.length ? (
tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))
) : (
<Tag className="m-0"></Tag>
)}
</div>
</div>
</div>
</div>
</div> </div>
</div> <input
</div> ref={coverInputRef}
</div> type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readCoverFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readImageFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</div> </div>
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => { );
void readCoverFile(event.target.files?.[0]);
event.target.value = "";
}} />
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
void readImageFile(event.target.files?.[0]);
event.target.value = "";
}} />
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</div>
);
} }
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) { function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
const summary = assetSummary(asset); const summary = assetSummary(asset);
return ( return (
<Card <Card
hoverable hoverable
className="overflow-hidden" className="overflow-hidden"
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 ? (
</button> <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 type="button" className="block w-full text-left" onClick={onOpen}> )}
<div className="p-4"> </button>
<div className="flex items-start justify-between gap-3"> }
<div className="min-w-0"> >
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2> <button type="button" className="block w-full text-left" onClick={onOpen}>
<Typography.Text type="secondary" className="mt-1 block text-xs">{asset.source || "未标注来源"}</Typography.Text> <div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Typography.Text type="secondary" className="mt-1 block text-xs">
{asset.source || "未标注来源"}
</Typography.Text>
</div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{(asset.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}>
</Button>
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>
</Button>
{asset.kind === "text" ? (
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>
</Button>
) : null}
{asset.kind === "image" ? (
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>
</Button>
) : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
</Button>
</div> </div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag> </Card>
</div> );
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{(asset.tags || []).slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}></Button>
{asset.kind === "text" ? <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}></Button> : null}
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}></Button> : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}></Button>
</div>
</Card>
);
} }
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) { function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : ""; const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
return ( return (
<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 ? (
<div> <Image src={cover} alt={asset.title} className="rounded-lg" />
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title> ) : (
<Space size={[4, 4]} wrap> <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>
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag> )}
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)} <div>
</Space> <Typography.Title level={4} className="!mb-2">
</div> {asset.title}
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800"> </Typography.Title>
<Typography.Text type="secondary" className="block text-xs"></Typography.Text> <Space size={[4, 4]} wrap>
{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>} <Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
</div> {(asset.tags || []).map((tag) => (
{asset.note ? <div><Typography.Text type="secondary"></Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null} <Tag key={tag}>{tag}</Tag>
<Space> ))}
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}></Button> : null} </Space>
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}></Button> : null} </div>
</Space> <div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
</div> <Typography.Text type="secondary" className="block text-xs">
) : null}
</Drawer> </Typography.Text>
); {asset.kind === "text" ? (
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
) : (
<Typography.Text className="mt-2 block">
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
</Typography.Text>
)}
</div>
{asset.note ? (
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
</div>
) : null}
<Space>
{asset.kind === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
</Button>
) : null}
{asset.kind === "image" ? (
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
</Button>
) : null}
</Space>
</div>
) : null}
</Drawer>
);
} }
function assetSummary(asset: Asset) { function assetSummary(asset: Asset) {
if (asset.kind === "text") return asset.data.content; if (asset.kind === "text") return asset.data.content;
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
} }
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
+1 -1
View File
@@ -1,5 +1,5 @@
import CanvasClientPage from "./canvas-client-page"; import CanvasClientPage from "./canvas-client-page";
export default function CanvasPage() { export default function CanvasPage() {
return <CanvasClientPage />; return <CanvasClientPage />;
} }
@@ -12,254 +12,251 @@ import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"
export type AssetPickerTab = "my-assets" | "library"; export type 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;
defaultTab?: AssetPickerTab; defaultTab?: AssetPickerTab;
onInsert: (payload: InsertAssetPayload) => void; onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void; onClose: () => void;
}; };
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) { export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab); const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
useEffect(() => { useEffect(() => {
if (open) setActiveTab(defaultTab); if (open) setActiveTab(defaultTab);
}, [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="选择素材" <Tabs
open={open} activeKey={activeTab}
onCancel={onClose} onChange={(key) => setActiveTab(key as AssetPickerTab)}
footer={null} items={[
width={860} { key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
destroyOnHidden { key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }} ]}
> />
<Tabs </Modal>
activeKey={activeTab} );
onChange={(key) => setActiveTab(key as AssetPickerTab)}
items={[
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
]}
/>
</Modal>
);
} }
const PAGE_SIZE = 8; const PAGE_SIZE = 8;
const kindOptions = [ const kindOptions = [
{ label: "全部", value: "all" }, { label: "全部", value: "all" },
{ label: "文本", value: "text" }, { label: "文本", value: "text" },
{ label: "图片", value: "image" }, { label: "图片", value: "image" },
]; ];
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) { function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState(""); const [kindFilter, setKindFilter] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [inserting, setInserting] = useState<string | null>(null); const [inserting, setInserting] = useState<string | null>(null);
const query = useQuery({ const query = useQuery({
queryKey: ["asset-picker-library", keyword, kindFilter, page], queryKey: ["asset-picker-library", keyword, kindFilter, page],
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }), queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
retry: false, retry: false,
}); });
const items = query.data?.items || []; const items = query.data?.items || [];
const total = query.data?.total || 0; const total = query.data?.total || 0;
const handleInsert = async (asset: AssetLibraryItem) => { const handleInsert = async (asset: AssetLibraryItem) => {
try { try {
setInserting(asset.id); setInserting(asset.id);
if (asset.type === "text") { if (asset.type === "text") {
onInsert({ kind: "text", content: asset.content, title: asset.title }); onInsert({ kind: "text", content: asset.content, title: asset.title });
} else { } else {
const dataUrl = await remoteImageToDataUrl(asset.url); const dataUrl = await remoteImageToDataUrl(asset.url);
onInsert({ kind: "image", dataUrl, title: asset.title }); onInsert({ kind: "image", dataUrl, title: asset.title });
} }
} catch { } catch {
message.error("插入失败"); message.error("插入失败");
} finally { } finally {
setInserting(null); setInserting(null);
} }
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Input <Input
className="w-56" className="w-56"
size="small" size="small"
prefix={<Search className="size-3.5 text-stone-400" />} prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材" placeholder="搜索素材"
value={keyword} value={keyword}
allowClear allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }} onChange={(e) => {
/> setPage(1);
<div className="flex gap-1.5"> setKeyword(e.target.value);
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => ( }}
<Tag.CheckableTag />
key={opt.value || "all"} <div className="flex gap-1.5">
checked={kindFilter === opt.value} {[
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")} { label: "全部", value: "" },
onChange={() => { setPage(1); setKindFilter(opt.value); }} { label: "文本", value: "text" },
> { label: "图片", value: "image" },
{opt.label} ].map((opt) => (
</Tag.CheckableTag> <Tag.CheckableTag
))} key={opt.value || "all"}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{query.isLoading ? (
<div className="flex justify-center py-16">
<Spin />
</div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div> </div>
</div> );
{query.isLoading ? (
<div className="flex justify-center py-16"><Spin /></div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard
key={asset.id}
title={asset.title}
kind={asset.type}
cover={asset.coverUrl}
loading={inserting === asset.id}
onClick={() => void handleInsert(asset)}
/>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
} }
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) { function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
return ( return (
<button <button
type="button" type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500" className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick} onClick={onClick}
disabled={loading} disabled={loading}
> >
{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="flex items-center justify-between gap-2">
<div className="p-2.5"> <span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<div className="flex items-center justify-between gap-2"> <Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag>
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span> </div>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag> </div>
</div> {loading && (
</div> <div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
{loading && ( <Spin size="small" />
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60"> </div>
<Spin size="small" /> )}
</div> <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
)} </button>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"> );
</div>
</button>
);
} }
async function remoteImageToDataUrl(url: string) { async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" }); const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob; const blob = response.data as Blob;
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "")); reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败")); reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}); });
} }
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) { function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const assets = useAssetStore((state) => state.assets); const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all"); const [kindFilter, setKindFilter] = useState("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase(); const query = keyword.trim().toLowerCase();
return assets return assets
.filter((a) => a.kind === "text" || a.kind === "image") .filter((a) => a.kind === "text" || a.kind === "image")
.filter((a) => kindFilter === "all" || a.kind === kindFilter) .filter((a) => kindFilter === "all" || a.kind === kindFilter)
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query)); .filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
}, [assets, keyword, kindFilter]); }, [assets, keyword, kindFilter]);
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]); const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
useEffect(() => { useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
setPage((v) => Math.min(v, maxPage)); setPage((v) => Math.min(v, maxPage));
}, [filtered.length]); }, [filtered.length]);
const handleInsert = (asset: Asset) => { const handleInsert = (asset: Asset) => {
if (asset.kind === "text") { if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title }); onInsert({ kind: "text", content: asset.data.content, title: asset.title });
} else { } else {
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }); onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
} }
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Input <Input
className="w-56" className="w-56"
size="small" size="small"
prefix={<Search className="size-3.5 text-stone-400" />} prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材" placeholder="搜索素材"
value={keyword} value={keyword}
allowClear allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }} onChange={(e) => {
/> setPage(1);
<div className="flex gap-1.5"> setKeyword(e.target.value);
{kindOptions.map((opt) => ( }}
<Tag.CheckableTag />
key={opt.value} <div className="flex gap-1.5">
checked={kindFilter === opt.value} {kindOptions.map((opt) => (
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")} <Tag.CheckableTag
onChange={() => { setPage(1); setKindFilter(opt.value); }} key={opt.value}
> checked={kindFilter === opt.value}
{opt.label} className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
</Tag.CheckableTag> onChange={() => {
))} setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div> </div>
</div> );
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
} }
File diff suppressed because it is too large Load Diff
@@ -14,241 +14,292 @@ import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
type CanvasConfigNodePanelProps = { type CanvasConfigNodePanelProps = {
node: CanvasNodeData; node: CanvasNodeData;
isRunning: boolean; isRunning: boolean;
inputSummary: { textCount: number; imageCount: number }; inputSummary: { textCount: number; imageCount: number };
inputs: NodeGenerationInput[]; inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void; onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void; onTextInputChange: (nodeId: string, content: string) => void;
onGenerate: (nodeId: string) => void; onGenerate: (nodeId: string) => void;
}; };
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) { export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
const { message } = App.useApp(); const { message } = App.useApp();
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null); const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState(""); const [editingText, setEditingText] = useState("");
const globalConfig = useConfigStore((state) => state.config); const globalConfig = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image"; const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode); const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1))); const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text"); const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image"); const imageInputs = inputs.filter((input) => input.type === "image");
const moveInput = (input: NodeGenerationInput, offset: number) => { const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type); const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId); const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
const targetInput = sameTypeInputs[sameTypeIndex + offset]; const targetInput = sameTypeInputs[sameTypeIndex + offset];
if (!targetInput) return; if (!targetInput) return;
const index = inputs.findIndex((item) => item.nodeId === input.nodeId); const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId); const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
const next = [...inputs]; const next = [...inputs];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]]; [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) }); onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
message.success("已调整输入顺序"); message.success("已调整输入顺序");
}; };
const startTextEdit = (input: NodeGenerationInput) => { const startTextEdit = (input: NodeGenerationInput) => {
setEditingTextId(input.nodeId); setEditingTextId(input.nodeId);
setEditingText(input.text || ""); setEditingText(input.text || "");
}; };
const saveTextEdit = () => { const saveTextEdit = () => {
if (!editingTextId) return; if (!editingTextId) return;
onTextInputChange(editingTextId, editingText); onTextInputChange(editingTextId, editingText);
setEditingText(""); setEditingText("");
setEditingTextId(null); setEditingTextId(null);
message.success("已保存文本提示词"); message.success("已保存文本提示词");
}; };
return ( return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}> <div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div> <div className="shrink-0 text-sm font-semibold"></div>
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}> <div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<Segmented <Segmented
size="small" size="small"
className="canvas-config-mode !rounded-md !p-0.5" className="canvas-config-mode !rounded-md !p-0.5"
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">
</div> <ImageIcon className="size-3.5" />
</div>
</span>
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}> ),
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} /> },
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} /> {
<button value: "text",
type="button" label: (
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" <span className="inline-flex items-center gap-1">
style={chipStyle} <MessageSquare className="size-3.5" />
onClick={() => setPreviewOpen(true)}
> </span>
<Eye className="size-3.5" /> ),
},
</button> ]}
</div> />
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div>
)}
>
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
{inputs.length ? (
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => <ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />)}
</div> </div>
</PreviewSection>
</div> </div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5"> <div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词"> <InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<div className="space-y-1.5"> <InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
{textInputs.map((input, index) => <TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />)} <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)}>
</div> <Eye className="size-3.5" />
</PreviewSection>
</div> </button>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}> </div>
{editingTextId ? (
<> <div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between"> <ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
<div className="text-sm font-semibold"></div> {mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
<Button size="small" type="text" onClick={() => setEditingTextId(null)}></Button> <InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div> </div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}></Button>
<Button size="small" type="primary" onClick={saveTextEdit}></Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)} )}
</div> >
</div> <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
</div> {inputs.length ? (
) : ( <div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" /> <div className="shrink-0">
)} <PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => (
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5">
{textInputs.map((input, index) => (
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
))}
</div>
</PreviewSection>
</div>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{editingTextId ? (
<>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
</Button>
</div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}>
</Button>
<Button size="small" type="primary" onClick={saveTextEdit}>
</Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)}
</div>
</div>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
)}
</div>
</Modal>
</div> </div>
</Modal> );
</div>
);
} }
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) { function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
return ( return (
<section> <section>
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm"> <div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
<div className="text-xs font-semibold">{title}</div> <div className="text-xs font-semibold">{title}</div>
<div className="text-[11px] opacity-50">{count} </div> <div className="text-[11px] opacity-50">{count} </div>
</div> </div>
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>} {count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
</section> </section>
); );
} }
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) { function TextSortCard({
return ( input,
<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 }}> textIndex,
<div className="min-w-0"> textTotal,
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div> inputs,
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div> theme,
</div> onMove,
<div className="flex justify-end gap-1"> onEdit,
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} /> }: {
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} /> input: NodeGenerationInput;
</div> textIndex: number;
</div> textTotal: number;
); inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
onEdit: (input: NodeGenerationInput) => void;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
<div className="min-w-0">
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div>
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
</div>
<div className="flex justify-end gap-1">
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
} }
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) { function ImageSortCard({
if (!input.image) return null; input,
return ( imageIndex,
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}> imageTotal,
<div className="relative"> inputs,
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" /> theme,
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageIndex + 1}</span> onMove,
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} /> }: {
</div> input: NodeGenerationInput;
</div> imageIndex: number;
); imageTotal: number;
inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.image) return null;
return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageIndex + 1}</span>
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
} }
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) { function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return ( return (
<> <>
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} /> <Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} /> <Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</> </>
); );
} }
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) { function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return ( return (
<div className="absolute inset-x-1 bottom-1 flex justify-between"> <div className="absolute inset-x-1 bottom-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} /> <Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} /> <Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</div> </div>
); );
} }
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) { function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
return ( return (
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}> <div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
<span>{label}</span> <span>{label}</span>
<span className="font-medium">{value}</span> <span className="font-medium">{value}</span>
</div> </div>
); );
} }
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig { function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel; const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return { return {
...globalConfig, ...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model, model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality, quality: globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size, size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count), count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
}; };
} }
@@ -2,72 +2,52 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { 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, const theme = canvasThemes[useThemeStore((state) => state.theme)];
from, const startX = from.position.x + from.width;
to, const startY = from.position.y + from.height / 2;
active, const endX = to.position.x;
onSelect, const endY = to.position.y + to.height / 2;
}: { const dx = Math.abs(endX - startX);
connection: CanvasConnection; const curvature = Math.max(dx * 0.5, 50);
from: CanvasNodeData; const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
to: CanvasNodeData;
active: boolean;
onSelect: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startX = from.position.x + from.width;
const startY = from.position.y + from.height / 2;
const endX = to.position.x;
const endY = to.position.y + to.height / 2;
const dx = Math.abs(endX - startX);
const curvature = Math.max(dx * 0.5, 50);
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
return ( return (
<g> <g>
<path <path
data-connection-id={connection.id} data-connection-id={connection.id}
d={pathD} d={pathD}
stroke="transparent" stroke="transparent"
strokeWidth="16" strokeWidth="16"
fill="none" fill="none"
style={{ cursor: "pointer", pointerEvents: "stroke" }} style={{ cursor: "pointer", pointerEvents: "stroke" }}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onSelect(); onSelect();
}} }}
/> />
<path <path
d={pathD} d={pathD}
stroke={active ? theme.node.activeStroke : theme.node.muted} stroke={active ? theme.node.activeStroke : theme.node.muted}
strokeWidth={active ? 3 : 2} strokeWidth={active ? 3 : 2}
strokeOpacity={active ? 1 : 0.82} strokeOpacity={active ? 1 : 0.82}
fill="none" fill="none"
style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }} style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }}
/> />
</g> </g>
); );
} }
export function ActiveConnectionPath({ export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
node, const theme = canvasThemes[useThemeStore((state) => state.theme)];
handle, if (!node) return null;
mouseWorld,
}: {
node?: CanvasNodeData;
handle: ConnectionHandle;
mouseWorld: Position;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!node) return null;
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x; const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y; const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x; const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2; const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
const distance = Math.abs(endX - startX); const distance = Math.abs(endX - startX);
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`; const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />; return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
} }
@@ -8,63 +8,38 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { 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, const theme = canvasThemes[useThemeStore((state) => state.theme)];
onClose,
onDuplicate,
onDelete,
}: {
menu: ContextMenuState;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => { useEffect(() => {
const close = (event: PointerEvent) => { const close = (event: PointerEvent) => {
const target = event.target; const target = event.target;
if (target instanceof Element && target.closest(".ant-popover")) return; if (target instanceof Element && target.closest(".ant-popover")) return;
onClose(); onClose();
}; };
window.addEventListener("pointerdown", close); window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close); return () => window.removeEventListener("pointerdown", close);
}, [onClose]); }, [onClose]);
return ( return (
<div <div
className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl" className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl"
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger /> <MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
</div> </div>
); );
} }
function MenuButton({ function MenuButton({ icon, label, onClick, danger = false }: { icon: ReactNode; label: string; onClick?: () => void; danger?: boolean }) {
icon, const theme = canvasThemes[useThemeStore((state) => state.theme)];
label,
onClick,
danger = false,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
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" {icon}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80" <span>{label}</span>
style={{ color: danger ? "#f87171" : theme.node.text }} </button>
onClick={onClick} );
>
{icon}
<span>{label}</span>
</button>
);
} }
@@ -7,27 +7,34 @@ import { useCanvasStore } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store"; import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() { export function CanvasDeleteProjectsDialog() {
const ids = useCanvasUiStore((state) => state.deleteProjectIds); const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds); const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
const deleteProjects = useCanvasStore((state) => state.deleteProjects); const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const cleanupImages = useAssetStore((state) => state.cleanupImages); const cleanupImages = useAssetStore((state) => state.cleanupImages);
const confirm = () => { const confirm = () => {
deleteProjects(ids); deleteProjects(ids);
cleanupImages(); cleanupImages();
removeSelectedIds(ids); removeSelectedIds(ids);
setDeleteIds([]); setDeleteIds([]);
}; };
return ( return (
<Modal <Modal
title="删除画布?" title="删除画布?"
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={
> <>
<p className="text-sm text-stone-500"> {ids.length} 线</p> <Button onClick={() => setDeleteIds([])}></Button>
</Modal> <Button danger type="primary" onClick={confirm}>
);
</Button>
</>
}
>
<p className="text-sm text-stone-500"> {ids.length} 线</p>
</Modal>
);
} }
@@ -0,0 +1,224 @@
"use client";
import { type ReactNode } from "react";
import { Settings2 } from "lucide-react";
import { Button, ConfigProvider, Popover } from "antd";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
const qualityOptions = [
{ value: "auto", label: "自动" },
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
];
const aspectOptions = [
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
];
type CanvasImageSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
onMissingConfig?: () => void;
onOpenChange?: (open: boolean) => void;
buttonClassName?: string;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const selectAspect = (value: string) => {
const option = aspectOptions.find((item) => item.value === value);
onConfigChange("size", option?.size || option?.value || "auto");
};
const updateDimension = (key: "width" | "height", value: number | null) => {
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
};
return (
<Popover
trigger="click"
placement={placement}
arrow={false}
overlayClassName="canvas-image-settings-popover"
color={theme.toolbar.panel}
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
onOpenChange={onOpenChange}
content={
<CanvasImageSettingsTheme theme={theme}>
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<div className="text-xl font-semibold"></div>
<div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3">
{qualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
</div>
</div>
<div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
<span className="text-lg opacity-45"></span>
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
</div>
</div>
<div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3">
{aspectOptions.map((item) => (
<button
key={item.value}
type="button"
className="flex h-[86px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-sm transition hover:opacity-80"
style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => selectAspect(item.value)}
>
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
<span>{item.label}</span>
</button>
))}
</div>
</div>
<div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
{value}
</OptionPill>
))}
<CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
</div>
</div>
</div>
</CanvasImageSettingsTheme>
}
>
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">
{qualityLabel(quality)} · {selectedAspect?.label || activeSize} · {count}
</span>
</Button>
</Popover>
);
}
export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
return (
<ConfigProvider
theme={{
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text } },
}}
>
{children}
</ConfigProvider>
);
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>
{children}
</button>
);
}
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
return (
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
{prefix}
</span>
<input
type="number"
min={1}
disabled={disabled}
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
value={value || ""}
onChange={(event) => onChange(Number(event.target.value) || null)}
onMouseDown={(event) => event.stopPropagation()}
/>
</label>
);
}
function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
return (
<label className="col-span-2 flex h-10 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<input
type="number"
min={1}
max={15}
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
value={value || ""}
onChange={(event) => onChange(Number(event.target.value) || null)}
onMouseDown={(event) => event.stopPropagation()}
/>
</label>
);
}
function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) {
if (type === "auto") return null;
const ratio = width / Math.max(1, height);
const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio);
const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28;
return (
<span className="grid h-8 w-10 place-items-center">
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
</span>
);
}
function SettingTitle({ children, color }: { children: string; color: string }) {
return (
<div className="text-xs font-medium" style={{ color }}>
{children}
</div>
);
}
function qualityLabel(value: string) {
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
}
function readSizeDimensions(size: string, fallback: { width: number; height: number }) {
const match = size?.match(/^(\d+)x(\d+)$/);
return {
width: match ? Number(match[1]) : fallback.width,
height: match ? Number(match[2]) : fallback.height,
};
}
@@ -6,149 +6,133 @@ 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, const theme = canvasThemes[useThemeStore((state) => state.theme)];
viewport, const containerRef = useRef<HTMLDivElement>(null);
viewportSize, const [isDragging, setIsDragging] = useState(false);
onViewportChange, const width = 240;
}: { const height = 160;
nodes: CanvasNodeData[];
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onViewportChange: (viewport: ViewportTransform) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const width = 240;
const height = 160;
const { worldBounds, scale, offset } = useMemo(() => { const { worldBounds, scale, offset } = useMemo(() => {
if (!nodes.length) { if (!nodes.length) {
return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } }; return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } };
} }
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
nodes.forEach((node) => { nodes.forEach((node) => {
minX = Math.min(minX, node.position.x); minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y); minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + node.width); maxX = Math.max(maxX, node.position.x + node.width);
maxY = Math.max(maxY, node.position.y + node.height); maxY = Math.max(maxY, node.position.y + node.height);
}); });
minX -= 500; minX -= 500;
minY -= 500; minY -= 500;
maxX += 500; maxX += 500;
maxY += 500; maxY += 500;
const boundsWidth = maxX - minX; const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY; const boundsHeight = maxY - minY;
const nextScale = Math.min(width / boundsWidth, height / boundsHeight); const nextScale = Math.min(width / boundsWidth, height / boundsHeight);
const mapContentW = boundsWidth * nextScale; const mapContentW = boundsWidth * nextScale;
const mapContentH = boundsHeight * nextScale; const mapContentH = boundsHeight * nextScale;
return { return {
worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight }, worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight },
scale: nextScale, scale: nextScale,
offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 }, offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 },
};
}, [nodes]);
const toMinimap = useCallback(
(worldX: number, worldY: number) => {
return {
x: (worldX - worldBounds.x) * scale + offset.x,
y: (worldY - worldBounds.y) * scale + offset.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const toWorld = useCallback(
(minimapX: number, minimapY: number) => {
return {
x: (minimapX - offset.x) / scale + worldBounds.x,
y: (minimapY - offset.y) / scale + worldBounds.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const viewportRect = useMemo(() => {
const vx = -viewport.x / viewport.k;
const vy = -viewport.y / viewport.k;
const vw = viewportSize.width / viewport.k;
const vh = viewportSize.height / viewport.k;
const p1 = toMinimap(vx, vy);
const p2 = toMinimap(vx + vw, vy + vh);
return {
x: p1.x,
y: p1.y,
w: Math.max(p2.x - p1.x, 4),
h: Math.max(p2.y - p1.y, 4),
};
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const updateViewportFromEvent = (event: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
onViewportChange({
x: viewportSize.width / 2 - world.x * viewport.k,
y: viewportSize.height / 2 - world.y * viewport.k,
k: viewport.k,
});
}; };
}, [nodes]);
const toMinimap = useCallback( return (
(worldX: number, worldY: number) => { <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 }}>
return {
x: (worldX - worldBounds.x) * scale + offset.x,
y: (worldY - worldBounds.y) * scale + offset.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const toWorld = useCallback(
(minimapX: number, minimapY: number) => {
return {
x: (minimapX - offset.x) / scale + worldBounds.x,
y: (minimapY - offset.y) / scale + worldBounds.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const viewportRect = useMemo(() => {
const vx = -viewport.x / viewport.k;
const vy = -viewport.y / viewport.k;
const vw = viewportSize.width / viewport.k;
const vh = viewportSize.height / viewport.k;
const p1 = toMinimap(vx, vy);
const p2 = toMinimap(vx + vw, vy + vh);
return {
x: p1.x,
y: p1.y,
w: Math.max(p2.x - p1.x, 4),
h: Math.max(p2.y - p1.y, 4),
};
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const updateViewportFromEvent = (event: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
onViewportChange({
x: viewportSize.width / 2 - world.x * viewport.k,
y: viewportSize.height / 2 - world.y * viewport.k,
k: viewport.k,
});
};
return (
<div
className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm"
style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
<div
ref={containerRef}
className="relative h-full w-full cursor-crosshair"
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
updateViewportFromEvent(event);
}}
onPointerMove={(event) => {
if (isDragging) updateViewportFromEvent(event);
}}
onPointerUp={() => setIsDragging(false)}
onPointerLeave={() => setIsDragging(false)}
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
return (
<div <div
key={node.id} ref={containerRef}
className="absolute rounded-[1px]" className="relative h-full w-full cursor-crosshair"
style={{ onPointerDown={(event) => {
left: pos.x, event.preventDefault();
top: pos.y, event.currentTarget.setPointerCapture(event.pointerId);
width: Math.max(node.width * scale, 2), setIsDragging(true);
height: Math.max(node.height * scale, 2), updateViewportFromEvent(event);
backgroundColor: color, }}
opacity: 0.8, onPointerMove={(event) => {
}} if (isDragging) updateViewportFromEvent(event);
/> }}
); onPointerUp={() => setIsDragging(false)}
})} onPointerLeave={() => setIsDragging(false)}
<div >
className="pointer-events-none absolute border" {nodes.map((node) => {
style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }} const pos = toMinimap(node.position.x, node.position.y);
/> const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
</div> return (
</div> <div
); key={node.id}
className="absolute rounded-[1px]"
style={{
left: pos.x,
top: pos.y,
width: Math.max(node.width * scale, 2),
height: Math.max(node.height * scale, 2),
backgroundColor: color,
opacity: 0.8,
}}
/>
);
})}
<div className="pointer-events-none absolute border" style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }} />
</div>
</div>
);
} }
@@ -5,87 +5,89 @@ import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react"; import { RotateCcw, WandSparkles } from "lucide-react";
export type CanvasImageAngleParams = { export type CanvasImageAngleParams = {
horizontalAngle: number; horizontalAngle: number;
pitchAngle: number; pitchAngle: number;
cameraDistance: number; cameraDistance: number;
wideAngle: boolean; wideAngle: boolean;
}; };
const defaultParams: CanvasImageAngleParams = { const defaultParams: CanvasImageAngleParams = {
horizontalAngle: 0, horizontalAngle: 0,
pitchAngle: 9, pitchAngle: 9,
cameraDistance: 4.8, cameraDistance: 4.8,
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; const [params, setParams] = useState(defaultParams);
open: boolean;
onClose: () => void;
onConfirm: (params: CanvasImageAngleParams) => void;
}) {
const [params, setParams] = useState(defaultParams);
useEffect(() => { useEffect(() => {
if (open) setParams(defaultParams); if (open) setParams(defaultParams);
}, [dataUrl, open]); }, [dataUrl, open]);
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value })); const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
return ( return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden> <Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h2 className="text-xl font-semibold">AI </h2> <h2 className="text-xl font-semibold">AI </h2>
<p className="mt-1 text-sm opacity-60"></p> <p className="mt-1 text-sm opacity-60"></p>
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]"> <div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4"> <div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
<div className="grid flex-1 place-items-center"> <div className="grid flex-1 place-items-center">
<div className="relative"> <div className="relative">
<img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} /> <img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} />
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" /> <div 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>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
</Button>
</div>
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<Segmented
className="w-fit"
value={params.wideAngle ? "wide" : "standard"}
options={[
{ label: "标准", value: "standard" },
{ label: "广角", value: "wide" },
]}
onChange={(value) => update("wideAngle", value === "wide")}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
AI
</Button>
</div>
</div> </div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}></Button> </Modal>
</div> );
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<Segmented className="w-fit" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI </Button>
</div>
</div>
</Modal>
);
} }
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: { 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; return (
value: number; <div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
min: number; <span className="font-medium opacity-75">{label}</span>
max: number; <Slider min={min} max={max} step={step} value={value} onChange={onChange} />
step: number; <span className="whitespace-nowrap text-right font-semibold">
suffix?: string; {Number.isInteger(value) ? value : value.toFixed(1)}
onChange: (value: number) => void; {suffix}
}) { </span>
return ( </div>
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4"> );
<span className="font-medium opacity-75">{label}</span>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
</div>
);
} }
function previewTransform(params: CanvasImageAngleParams) { function previewTransform(params: CanvasImageAngleParams) {
const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0); const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0);
return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`; return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`;
} }
@@ -7,10 +7,10 @@ import { Check, Lock, LockOpen, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils"; import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = { export type CanvasImageCropRect = {
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
}; };
type DragMode = "move" | "resize"; type DragMode = "move" | "resize";
@@ -20,151 +20,149 @@ 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, const boxRef = useRef<HTMLDivElement>(null);
open, const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
onClose, const [locked, setLocked] = useState(false);
onConfirm, const [image, setImage] = useState<{ width: number; height: number } | null>(null);
}: { const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (crop: CanvasImageCropRect) => void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
useEffect(() => { useEffect(() => {
if (open) setCrop(defaultCrop); if (open) setCrop(defaultCrop);
}, [dataUrl, open]); }, [dataUrl, open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
void readImageMeta(dataUrl).then(setImage); void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]); }, [dataUrl, open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => { const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect(); const box = boxRef.current?.getBoundingClientRect();
if (!box) return; if (!box) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, crop }; const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => { const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width; const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height; const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box)); setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
}; };
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
};
return ( return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden> <Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-center"> <div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none"> <div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} /> <img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} /> <CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}> <div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" /> <div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" /> <div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" /> <div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" /> <div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => ( {handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" /> <button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))} ))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? (
<span>
{image.width} x {image.height}
</span>
) : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}>
</Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
</Button>
</div>
</div> </div>
</div> </Modal>
</div> );
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? <span> {image.width} x {image.height}</span> : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}></Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}></Button>
</div>
</div>
</Modal>
);
} }
function CropMask({ crop }: { crop: CanvasImageCropRect }) { function CropMask({ crop }: { crop: CanvasImageCropRect }) {
return ( return (
<> <>
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} /> <div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} /> <div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} /> <div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} /> <div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
</> </>
); );
} }
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect { function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) }; return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
} }
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect { function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect {
let next = { ...crop }; let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx; if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy; if (handle.includes("s")) next.height = crop.height + dy;
if (handle.includes("w")) { if (handle.includes("w")) {
next.x = crop.x + dx; next.x = crop.x + dx;
next.width = crop.width - dx; next.width = crop.width - dx;
} }
if (handle.includes("n")) { if (handle.includes("n")) {
next.y = crop.y + dy; next.y = crop.y + dy;
next.height = crop.height - dy; next.height = crop.height - dy;
} }
if (locked) { if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height); const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width; next.width = size / box.width;
next.height = size / box.height; next.height = size / box.height;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width; if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height; if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
} }
next.width = clamp(next.width, minSize, 1); next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1); next.height = clamp(next.height, minSize, 1);
next.x = clamp(next.x, 0, 1 - next.width); next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height); next.y = clamp(next.y, 0, 1 - next.height);
return next; return next;
} }
function cropStyle(crop: CanvasImageCropRect) { function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` }; return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
} }
function handleStyle(handle: ResizeHandle) { function handleStyle(handle: ResizeHandle) {
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)"; const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)"; const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
return { top, left, cursor: `${handle}-resize` }; return { top, left, cursor: `${handle}-resize` };
} }
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function formatRatio(width: number, height: number) { function formatRatio(width: number, height: number) {
const divisor = gcd(width, height); const divisor = gcd(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
} }
function gcd(a: number, b: number): number { function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : Math.max(1, a); return b ? gcd(b, a % b) : Math.max(1, a);
} }
@@ -3,98 +3,86 @@ import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type NodeGenerationContext = { export type NodeGenerationContext = {
prompt: string; prompt: string;
referenceImages: ReferenceImage[]; referenceImages: ReferenceImage[];
textCount: number; textCount: number;
imageCount: number; imageCount: number;
}; };
export type NodeGenerationInput = { export type NodeGenerationInput = {
nodeId: string; nodeId: string;
type: "text" | "image"; type: "text" | "image";
title: string; title: string;
text?: string; text?: string;
image?: ReferenceImage; image?: ReferenceImage;
}; };
export function buildNodeGenerationContext( export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
nodeId: string, const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
nodes: CanvasNodeData[], const upstreamText = inputs
connections: CanvasConnection[], .map((input) => input.text)
prompt: string, .filter(Boolean)
): NodeGenerationContext { .join("\n\n");
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections); const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
return { return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt, prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages, referenceImages,
textCount: inputs.filter((input) => input.type === "text").length, textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length, imageCount: referenceImages.length,
}; };
} }
export function buildNodeGenerationInputs( export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
nodeId: string, return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
nodes: CanvasNodeData[], const image = readReferenceImage(node);
connections: CanvasConnection[], if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
): NodeGenerationInput[] { const text = readNodeTextInput(node);
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => { if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
const image = readReferenceImage(node); return [];
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }]; });
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
});
} }
export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompletionMessage[] { export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompletionMessage[] {
if (!context.referenceImages.length) { if (!context.referenceImages.length) {
return [{ role: "user", content: context.prompt }]; return [{ role: "user", content: context.prompt }];
} }
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 } })), ];
],
},
];
} }
export async function hydrateNodeGenerationContext(context: NodeGenerationContext) { export async function hydrateNodeGenerationContext(context: NodeGenerationContext) {
const { imageToDataUrl } = await import("@/services/image-storage"); const { imageToDataUrl } = await import("@/services/image-storage");
return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) }; return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) };
} }
function readNodeTextInput(node: CanvasNodeData) { function readNodeTextInput(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || ""; if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return node.metadata?.prompt || ""; return node.metadata?.prompt || "";
} }
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null { function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null; if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
return { return {
id: node.id, id: node.id,
name: `${node.title || node.id}.png`, name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png", type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content, dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey, storageKey: node.metadata.storageKey,
}; };
} }
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId); const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections const upstreamNodes = connections
.filter((connection) => connection.toNodeId === nodeId) .filter((connection) => connection.toNodeId === nodeId)
.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)),
];
} }
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Modal, Segmented, Tooltip } from "antd"; import { Modal, Segmented, Tooltip } from "antd";
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload } from "lucide-react"; import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
@@ -10,182 +10,201 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types"; import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
type CanvasNodeHoverToolbarProps = { type CanvasNodeHoverToolbarProps = {
node: CanvasNodeData | null; node: CanvasNodeData | null;
viewport: ViewportTransform; viewport: ViewportTransform;
onKeep: (nodeId: string) => void; onKeep: (nodeId: string) => void;
onLeave: () => void; onLeave: () => void;
onInfo: (node: CanvasNodeData) => void; onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void; onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void; onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void; onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void; onToggleDialog: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void; onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void; onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void; onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void; onSaveAsset: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
}; };
export function CanvasNodeHoverToolbar({ export function CanvasNodeHoverToolbar({
node, node,
viewport, viewport,
onKeep, onKeep,
onLeave, onLeave,
onInfo, onInfo,
onEditText, onEditText,
onDecreaseFont, onDecreaseFont,
onIncreaseFont, onIncreaseFont,
onToggleDialog, onToggleDialog,
onGenerateImage, onGenerateImage,
onUpload, onUpload,
onDownload, onDownload,
onSaveAsset, onSaveAsset,
onCrop, onCrop,
onAngle, onAngle,
onRetry, onViewImage,
onToggleFreeResize, onRetry,
onDelete, onToggleFreeResize,
onDelete,
}: CanvasNodeHoverToolbarProps) { }: CanvasNodeHoverToolbarProps) {
if (!node) return null; if (!node) return null;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k; const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const top = viewport.y + node.position.y * viewport.k - 14; const top = viewport.y + node.position.y * viewport.k - 14;
const isImage = node.type === CanvasNodeType.Image; const isImage = node.type === CanvasNodeType.Image;
const hasImage = isImage && Boolean(node.metadata?.content); const hasImage = isImage && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text; const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config; const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage; const canOpenDialog = isText || hasImage;
const canRetry = node.metadata?.status === "error"; const canRetry = node.metadata?.status === "error";
const hasSpecificTools = canRetry || isText || isImage || isConfig; const hasSpecificTools = canRetry || isText || isImage || isConfig;
return ( return (
<div <div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]" className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }} style={{ left, top }}
onMouseEnter={() => onKeep(node.id)} onMouseEnter={() => onKeep(node.id)}
onMouseLeave={onLeave} onMouseLeave={onLeave}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} /> <ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger /> <ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
{hasSpecificTools ? <ToolbarDivider /> : null} {hasSpecificTools ? <ToolbarDivider /> : null}
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null} {canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
{hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null} {hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
{hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null} {hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null} {canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null} {isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null} {isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null} {isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null} {isText ? <ToolbarAction title="减小字号" label="缩小" icon={<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 ? (
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null} <ToolbarAction
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null} title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
</div> label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
); icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
onClick={() => onToggleFreeResize(node)}
active={node.metadata?.freeResize}
/>
) : null}
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
</div>
);
} }
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) { export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [view, setView] = useState<"info" | "json">("info"); const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0; const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0; const 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(
if (key === "title") return undefined; node,
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) { (key, value) => {
return "[base64 image]"; if (key === "title") return undefined;
} if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return value; return "[base64 image]";
}, 2); }
}, [node]); return value;
},
2,
);
}, [node]);
useEffect(() => { useEffect(() => {
if (open) setView("info"); if (open) setView("info");
}, [node?.id, open]); }, [node?.id, open]);
const title = ( const title = (
<div className="flex items-center justify-between gap-4 pr-12"> <div className="flex items-center justify-between gap-4 pr-12">
<span></span> <span></span>
<Segmented <Segmented
size="small" size="small"
value={view} value={view}
onChange={(value) => setView(value as "info" | "json")} onChange={(value) => setView(value as "info" | "json")}
options={[ options={[
{ label: "信息", value: "info" }, { label: "信息", value: "info" },
{ label: "JSON", value: "json" }, { label: "JSON", value: "json" },
]} ]}
/> />
</div>
);
return (
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
{node ? (
<div className="h-[56vh] min-h-[360px] text-sm">
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>{node.metadata.errorDetails}</div> : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
</div> </div>
) : null} );
</Modal>
); return (
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
{node ? (
<div className="h-[56vh] min-h-[360px] text-sm">
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? (
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
</div>
) : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
</div>
) : null}
</Modal>
);
} }
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) { function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
return ( return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}> <Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}> <button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}> <span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon} {icon}
<span>{label}</span> <span>{label}</span>
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null} {hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
); );
} }
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) { function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
return ( return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}> <Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}> <button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span> <span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
</button> </button>
</Tooltip> </Tooltip>
); );
} }
function ToolbarDivider() { function ToolbarDivider() {
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />; return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
} }
function InfoRow({ label, value }: { label: string; value: ReactNode }) { function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3"> <div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
<span className="opacity-50">{label}</span> <span className="opacity-50">{label}</span>
<span className="min-w-0 whitespace-pre-wrap break-words">{value}</span> <span className="min-w-0 whitespace-pre-wrap break-words">{value}</span>
</div> </div>
); );
} }
@@ -2,110 +2,119 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle } from "lucide-react"; import { ArrowUp, LoaderCircle } from "lucide-react";
import { Button, InputNumber } from "antd"; import { Button } from "antd";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library"; import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasSizePicker } from "./canvas-size-picker";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
export type CanvasNodeGenerationMode = CanvasGenerationMode; export type CanvasNodeGenerationMode = CanvasGenerationMode;
type CanvasNodePromptPanelProps = { type CanvasNodePromptPanelProps = {
node: CanvasNodeData; node: CanvasNodeData;
isRunning: boolean; isRunning: boolean;
onPromptChange: (nodeId: string, prompt: string) => void; onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void; onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void;
}; };
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) { export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
const globalConfig = useConfigStore((state) => state.config); const globalConfig = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type); const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode); const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent; const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || ""); const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
useEffect(() => { useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || ""); setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]); }, [isEditingExistingContent, node.id]);
const updatePrompt = (value: string) => { const updatePrompt = (value: string) => {
setPrompt(value); setPrompt(value);
if (!isEditingExistingContent) onPromptChange(node.id, value); if (!isEditingExistingContent) onPromptChange(node.id, value);
}; };
const submit = () => { const submit = () => {
const text = prompt.trim(); const text = prompt.trim();
if (!text || isRunning) return; if (!text || isRunning) return;
onGenerate(node.id, mode, text); onGenerate(node.id, mode, text);
setPrompt(""); setPrompt("");
}; };
return ( return (
<div <div
className="rounded-2xl border p-3 shadow-2xl backdrop-blur" className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}
> >
<textarea <textarea
value={prompt} value={prompt}
onChange={(event) => updatePrompt(event.target.value)} onChange={(event) => updatePrompt(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return; if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault(); event.preventDefault();
submit(); submit();
}} }}
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">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<CanvasPromptLibrary onSelect={updatePrompt} /> <CanvasPromptLibrary onSelect={updatePrompt} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> {mode === "image" ? (
{mode === "image" ? ( <>
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> <ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
) : null} <CanvasImageSettingsPopover
{mode === "image" ? ( config={config}
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 shrink-0 !w-[58px]" value={Math.floor(Math.abs(Number(config.count)) || 1)} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} /> placement="topLeft"
) : null} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => openConfigDialog(true)}
onOpenChange={onImageSettingsOpenChange}
/>
</>
) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
)}
</div>
<Button
type="primary"
shape="circle"
className="!h-10 !w-10 !min-w-10 shrink-0"
disabled={isRunning || !prompt.trim()}
onClick={submit}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
aria-label="生成"
/>
</div>
</div> </div>
<Button );
type="primary"
shape="circle"
className="!h-10 !w-10 !min-w-10 shrink-0"
disabled={isRunning || !prompt.trim()}
onClick={submit}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
aria-label="生成"
/>
</div>
</div>
);
} }
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text ? "text" : "image"; return type === CanvasNodeType.Text ? "text" : "image";
} }
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig { function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel; const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return { return {
...globalConfig, ...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model, model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size, size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count), count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -8,71 +8,80 @@ import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store"; import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
export function CanvasProjectCard({ project }: { project: CanvasProject }) { export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter(); const router = useRouter();
const renameProject = useCanvasStore((state) => state.renameProject); const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds); const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId); const editingId = useCanvasUiStore((state) => state.editingProjectId);
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle); const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
const startEditing = useCanvasUiStore((state) => state.startEditingProject); const startEditing = useCanvasUiStore((state) => state.startEditingProject);
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle); const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject); const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId); const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id; const editing = editingId === project.id;
const selected = selectedIds.includes(project.id); const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`); const open = () => router.push(`/canvas/${project.id}`);
const saveTitle = () => { const saveTitle = () => {
renameProject(project.id, editingTitle); renameProject(project.id, editingTitle);
stopEditing(); stopEditing();
}; };
return ( return (
<article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}> <article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<input <input
type="checkbox" type="checkbox"
checked={selected} checked={selected}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)} onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100" className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`} aria-label={`选择 ${project.title}`}
/> />
{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
<h2 className="truncate text-xl font-semibold">{project.title}</h2> type="button"
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} · {project.connections.length} 线</p> className="min-w-0 cursor-pointer text-left"
</button> onClick={(event) => {
)} event.stopPropagation();
</div> open();
<div className="mt-8 flex items-end justify-between gap-3"> }}
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p> >
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}> <h2 className="truncate text-xl font-semibold">{project.title}</h2>
{editing ? ( <p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
<> {project.nodes.length} · {project.connections.length} 线
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" /> </p>
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" /> </button>
</> )}
) : ( </div>
<> <div className="mt-8 flex items-end justify-between gap-3">
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" /> <p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" /> <div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" /> {editing ? (
</> <>
)} <Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
</div> <Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
</div> </>
</article> ) : (
); <>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
)}
</div>
</div>
</article>
);
} }
function exportProject(project: CanvasProject) { function exportProject(project: CanvasProject) {
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project }; const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })); const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`; link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@@ -9,15 +9,22 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) { export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
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
</Tooltip> type="text"
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} /> className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
</> style={{ color: theme.node.text }}
); icon={<BookOpen className="size-3.5" />}
onClick={() => setOpen(true)}
aria-label="提示词库"
/>
</Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
</>
);
} }
@@ -8,56 +8,60 @@ import { cn } from "@/lib/utils";
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"]; const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
type CanvasSizePickerProps = { type CanvasSizePickerProps = {
value: string; value: string;
className?: string; className?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) { export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item)); const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size })); const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
const selectSize = (next: string) => { const selectSize = (next: string) => {
onChange(next.trim()); onChange(next.trim());
setSearch(""); setSearch("");
setOpen(false); setOpen(false);
};
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
setOpen(false);
}; };
window.addEventListener("pointerdown", close, true);
return () => window.removeEventListener("pointerdown", close, true);
}, [open]);
return ( useEffect(() => {
<div ref={rootRef} className={className}> if (!open) return;
<Select const close = (event: PointerEvent) => {
showSearch const target = event.target instanceof Element ? event.target : null;
open={open} if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
className={cn("canvas-compact-control canvas-control-select h-full w-full")} setOpen(false);
value={value || undefined} };
searchValue={search} window.addEventListener("pointerdown", close, true);
placeholder="比例" return () => window.removeEventListener("pointerdown", close, true);
options={options} }, [open]);
popupMatchSelectWidth={false}
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>} return (
onOpenChange={setOpen} <div ref={rootRef} className={className}>
onSearch={setSearch} <Select
onChange={selectSize} showSearch
onBlur={() => { open={open}
if (search.trim()) selectSize(search); className={cn("canvas-compact-control canvas-control-select h-full w-full")}
}} value={value || undefined}
onInputKeyDown={(event) => { searchValue={search}
if (event.key === "Enter" && search.trim()) selectSize(search); placeholder="比例"
}} options={options}
/> popupMatchSelectWidth={false}
</div> popupRender={(menu) => (
); <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onOpenChange={setOpen}
onSearch={setSearch}
onChange={selectSize}
onBlur={() => {
if (search.trim()) selectSize(search);
}}
onInputKeyDown={(event) => {
if (event.key === "Enter" && search.trim()) selectSize(search);
}}
/>
</div>
);
} }
@@ -8,259 +8,273 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
export function CanvasToolbar({ export function CanvasToolbar({
selectedCount, selectedCount,
canUndo, canUndo,
canRedo, canRedo,
backgroundMode, backgroundMode,
onAddImage, onAddImage,
onAddText, onAddText,
onAddConfig, onAddConfig,
onUndo, onUndo,
onRedo, onRedo,
onUpload, onUpload,
onDelete, onDelete,
onClear, onClear,
onDeselect, onDeselect,
onBackgroundModeChange, onBackgroundModeChange,
onOpenAssetLibrary, onOpenAssetLibrary,
onOpenMyAssets, onOpenMyAssets,
}: { }: {
selectedCount: number; selectedCount: number;
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
backgroundMode: CanvasBackgroundMode; backgroundMode: CanvasBackgroundMode;
onAddImage: () => void; onAddImage: () => void;
onAddText: () => void; onAddText: () => void;
onAddConfig: () => void; onAddConfig: () => void;
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onUpload: () => void; onUpload: () => void;
onDelete: () => void; onDelete: () => void;
onClear: () => void; onClear: () => void;
onDeselect: () => void; onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onOpenAssetLibrary: () => void; onOpenAssetLibrary: () => void;
onOpenMyAssets: () => void; onOpenMyAssets: () => void;
}) { }) {
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const [hovered, setHovered] = useState<string | null>(null); const [hovered, setHovered] = useState<string | null>(null);
const [tipX, setTipX] = useState(0); const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false); const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0); const [panelX, setPanelX] = useState(0);
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" }; const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText }; const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText }; const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : ""; const tip = hovered ? toolLabel(hovered) : "";
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} <ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
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" <Hand className="size-4.5" />
style={dockStyle} </ToolbarButton>
> <ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}> <Undo2 className="size-4.5" />
<Hand className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}> <Redo2 className="size-4.5" />
<Undo2 className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <Divider theme={theme} />
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}> <ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Redo2 className="size-4.5" /> <Type className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<Divider theme={theme} /> <ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}> <ImageIcon className="size-4.5" />
<Type className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}> <Settings2 className="size-4.5" />
<ImageIcon className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}> <Upload className="size-4.5" />
<Settings2 className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <Divider theme={theme} />
<ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}> <ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
<Upload className="size-4.5" /> <Library className="size-4.5" />
</ToolbarButton> </ToolbarButton>
<Divider theme={theme} /> <ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}> <FolderOpen className="size-4.5" />
<Library className="size-4.5" /> </ToolbarButton>
</ToolbarButton> <ToolbarButton
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}> id="tool-style"
<FolderOpen className="size-4.5" /> label="画布外观"
</ToolbarButton> active={appearanceOpen}
<ToolbarButton hovered={hovered}
id="tool-style" activeStyle={activeStyle}
label="画布外观" hoverStyle={hoverStyle}
active={appearanceOpen} wrapRef={wrapRef}
hovered={hovered} onTipX={setTipX}
activeStyle={activeStyle} onHover={setHovered}
hoverStyle={hoverStyle} onClick={(event) => {
wrapRef={wrapRef} setPanelX(getTipX(wrapRef.current, event.currentTarget));
onTipX={setTipX} setAppearanceOpen((value) => !value);
onHover={setHovered} }}
onClick={(event) => { >
setPanelX(getTipX(wrapRef.current, event.currentTarget)); <Palette className="size-4.5" />
setAppearanceOpen((value) => !value); </ToolbarButton>
}} {selectedCount ? (
> <>
<Palette className="size-4.5" /> <Divider theme={theme} />
</ToolbarButton> <ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
{selectedCount ? ( <Trash2 className="size-4.5" />
<> </ToolbarButton>
<Divider theme={theme} /> </>
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger> ) : null}
<Trash2 className="size-4.5" /> <Divider theme={theme} />
</ToolbarButton> <ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
</> <Eraser className="size-4.5" />
) : null} </ToolbarButton>
<Divider theme={theme} /> </div>
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" />
</ToolbarButton>
</div>
{appearanceOpen ? ( {appearanceOpen ? (
<div <div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur" className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }} style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
> >
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div> <div className="px-1 pb-2 text-sm font-medium opacity-65"></div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div> <div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}> <div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}> <CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" /> <Sun className="size-4" />
</CanvasThemeButton> </CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}> <CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" /> <Moon className="size-4" />
</CanvasThemeButton> </CanvasThemeButton>
</div> </div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div> <div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<Segmented <Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8" className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode} 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>
) : null}
</div> </div>
) : null} );
</div>
);
} }
function ToolbarButton({ function ToolbarButton({
id, id,
label, label,
active, active,
hovered, hovered,
activeStyle, activeStyle,
hoverStyle, hoverStyle,
wrapRef, wrapRef,
onTipX, onTipX,
onHover, onHover,
onClick, onClick,
disabled = false, disabled = false,
danger = false, danger = false,
children, children,
}: { }: {
id: string; id: string;
label: string; label: string;
active?: boolean; active?: boolean;
hovered: string | null; hovered: string | null;
activeStyle?: CSSProperties; activeStyle?: CSSProperties;
hoverStyle: CSSProperties; hoverStyle: CSSProperties;
wrapRef: RefObject<HTMLDivElement | null>; wrapRef: RefObject<HTMLDivElement | null>;
onTipX: (x: number) => void; onTipX: (x: number) => void;
onHover: (id: string | null) => void; onHover: (id: string | null) => void;
onClick?: (event: ReactMouseEvent<HTMLElement>) => void; onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
disabled?: boolean; disabled?: boolean;
danger?: boolean; danger?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
return ( return (
<Button <Button
type="text" type="text"
aria-label={label} aria-label={label}
className="!h-8 !w-8 !min-w-8 !p-0" className="!h-8 !w-8 !min-w-8 !p-0"
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) => {
onMouseLeave={() => onHover(null)} onHover(id);
onClick={onClick} onTipX(getTipX(wrapRef.current, event.currentTarget));
/> }}
); onMouseLeave={() => onHover(null)}
onClick={onClick}
/>
);
} }
function Divider({ theme }: { theme: CanvasTheme }) { 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, const theme = canvasThemes[colorTheme];
targetTheme, const active = colorTheme === targetTheme;
onThemeChange, const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
children,
}: {
colorTheme: CanvasColorTheme;
targetTheme: CanvasColorTheme;
onThemeChange: (theme: CanvasColorTheme) => void;
children: ReactNode;
}) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return ( return (
<AnimatedThemeToggler <AnimatedThemeToggler
theme={colorTheme} theme={colorTheme}
targetTheme={targetTheme} targetTheme={targetTheme}
onThemeChange={onThemeChange} onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition" className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }} style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`} aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`} title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
> >
{children} {children}
</AnimatedThemeToggler> </AnimatedThemeToggler>
); );
} }
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) {
if (id === "tool-hand") return "移动/选择"; if (id === "tool-hand") return "移动/选择";
if (id === "tool-undo") return "撤销"; if (id === "tool-undo") return "撤销";
if (id === "tool-redo") return "重做"; if (id === "tool-redo") return "重做";
if (id === "tool-text") return "文本"; if (id === "tool-text") return "文本";
if (id === "tool-image") return "图片"; if (id === "tool-image") return "图片";
if (id === "tool-config") return "生成配置"; if (id === "tool-config") return "生成配置";
if (id === "tool-upload") return "上传图片"; if (id === "tool-upload") return "上传图片";
if (id === "tool-library") return "素材库"; if (id === "tool-library") return "素材库";
if (id === "tool-assets") return "我的素材"; if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观"; if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中"; if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布"; if (id === "tool-clear") return "清空画布";
return ""; return "";
} }
function getTipX(wrap: HTMLDivElement | null, target: HTMLElement) { function getTipX(wrap: HTMLDivElement | null, target: HTMLElement) {
if (!wrap) return 0; if (!wrap) return 0;
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect(); const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
const box = target.getBoundingClientRect(); const box = target.getBoundingClientRect();
return box.left - wrapBox.left + box.width / 2; return box.left - wrapBox.left + box.width / 2;
} }
@@ -7,68 +7,75 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
type CanvasZoomControlsProps = { type CanvasZoomControlsProps = {
scale: number; scale: number;
onScaleChange: (scale: number) => void; onScaleChange: (scale: number) => void;
onReset: () => void; onReset: () => void;
isMiniMapOpen: boolean; isMiniMapOpen: boolean;
onToggleMiniMap: () => void; onToggleMiniMap: () => void;
}; };
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) { export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
const colorTheme = useThemeStore((state) => state.theme); const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme]; const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" }; const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText }; const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return ( return (
<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
</Tooltip> type="text"
<Tooltip title="重置视图"> className="!h-8 !w-8 !min-w-8 !p-0"
<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="重置视图" /> style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
</Tooltip> icon={<Compass className="size-4" />}
<Tooltip title="放大/缩小画布"> onClick={onToggleMiniMap}
<input aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
type="range" />
min="5" </Tooltip>
max="500" <Tooltip title="重置视图">
step="1" <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="重置视图" />
value={Math.round(scale * 100)} </Tooltip>
className="w-24" <Tooltip title="放大/缩小画布">
style={{ accentColor: theme.node.activeStroke }} <input
onChange={(event) => onScaleChange(Number(event.target.value) / 100)} type="range"
aria-label="放大/缩小画布" min="5"
/> max="500"
</Tooltip> step="1"
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}> value={Math.round(scale * 100)}
{Math.round(scale * 100)}% className="w-24"
</span> style={{ accentColor: theme.node.activeStroke }}
<Tooltip title="快捷键"> onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" /> aria-label="放大/缩小画布"
</Tooltip> />
</div> </Tooltip>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered> <span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}> {Math.round(scale * 100)}%
<Shortcut label="拖动画布" value="平移视图" /> </span>
<Shortcut label="滚轮" value="缩放画布" /> <Tooltip title="快捷键">
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" /> <Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" /> </Tooltip>
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" /> </div>
<Shortcut label="Delete / Backspace" value="删除选中" /> <Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" />
<Shortcut label="滚轮" value="缩放画布" />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
<Shortcut label="Delete / Backspace" value="删除选中" />
</div>
</Modal>
</div> </div>
</Modal> );
</div>
);
} }
function Shortcut({ label, value }: { label: ReactNode; value: string }) { function Shortcut({ label, value }: { label: ReactNode; value: string }) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="text-base font-medium">{label}</span> <span className="text-base font-medium">{label}</span>
<span className="opacity-60">{value}</span> <span className="opacity-60">{value}</span>
</div> </div>
); );
} }
@@ -7,216 +7,209 @@ import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "../types"; import type { ViewportTransform } from "../types";
type InfiniteCanvasProps = { type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform; viewport: ViewportTransform;
backgroundMode?: CanvasBackgroundMode; backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void; onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void; onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void; onCanvasDeselect?: () => void;
onContextMenu?: (event: React.MouseEvent) => void; onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void; onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode; children: React.ReactNode;
}; };
export function InfiniteCanvas({ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
containerRef, const theme = canvasThemes[useThemeStore((state) => state.theme)];
viewport, const panState = useRef({
backgroundMode = "lines", isPanning: false,
onViewportChange, startX: 0,
onCanvasMouseDown, startY: 0,
onCanvasDeselect, initialX: 0,
onContextMenu, initialY: 0,
onDrop,
children,
}: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
startX: 0,
startY: 0,
initialX: 0,
initialY: 0,
hasMoved: false,
});
const scaleRef = useRef(viewport.k);
const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
useEffect(() => {
scaleRef.current = viewport.k;
}, [viewport.k]);
useEffect(() => () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
setIsSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
const delta = -event.deltaY;
const factor = Math.pow(1.1, delta / 100);
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
onViewportChange({
x: mouseX - worldX * newScale,
y: mouseY - worldY * newScale,
k: newScale,
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-connection-create-menu]")) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
isPanning: true,
startX: event.clientX,
startY: event.clientY,
initialX: viewport.x,
initialY: viewport.y,
hasMoved: false, hasMoved: false,
}; });
document.body.style.cursor = "grabbing"; const scaleRef = useRef(viewport.k);
return; const frameRef = useRef<number | null>(null);
} const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
if (event.button === 0 && isSpacePressed && isBackgroundClick) { useEffect(() => {
event.preventDefault(); scaleRef.current = viewport.k;
} }, [viewport.k]);
};
useEffect(() => { useEffect(
const handlePointerMove = (event: PointerEvent) => { () => () => {
if (!panState.current.isPanning) return; if (frameRef.current) cancelAnimationFrame(frameRef.current);
},
[],
);
const dx = event.clientX - panState.current.startX; useEffect(() => {
const dy = event.clientY - panState.current.startY; const handleKeyDown = (event: KeyboardEvent) => {
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (event.code !== "Space") return;
panState.current.hasMoved = true; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
} setIsSpacePressed(true);
};
nextViewportRef.current = { const handleKeyUp = (event: KeyboardEvent) => {
x: panState.current.initialX + dx, if (event.code === "Space") setIsSpacePressed(false);
y: panState.current.initialY + dy, };
k: scaleRef.current,
}; window.addEventListener("keydown", handleKeyDown);
if (frameRef.current) return; window.addEventListener("keyup", handleKeyUp);
frameRef.current = requestAnimationFrame(() => { return () => {
frameRef.current = null; window.removeEventListener("keydown", handleKeyDown);
if (nextViewportRef.current) onViewportChange(nextViewportRef.current); window.removeEventListener("keyup", handleKeyUp);
}); };
}, []);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
const delta = -event.deltaY;
const factor = Math.pow(1.1, delta / 100);
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
onViewportChange({
x: mouseX - worldX * newScale,
y: mouseY - worldY * newScale,
k: newScale,
});
}; };
const handlePointerUp = () => { const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!panState.current.isPanning) return; const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom]")) return;
if (target?.closest("[data-connection-create-menu]")) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
if (!panState.current.hasMoved) { if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
onCanvasDeselect?.(); event.preventDefault();
} event.currentTarget.setPointerCapture(event.pointerId);
panState.current.isPanning = false; onCanvasMouseDown?.(event);
document.body.style.cursor = "default"; return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
isPanning: true,
startX: event.clientX,
startY: event.clientY,
initialX: viewport.x,
initialY: viewport.y,
hasMoved: false,
};
document.body.style.cursor = "grabbing";
return;
}
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
event.preventDefault();
}
}; };
window.addEventListener("pointermove", handlePointerMove); useEffect(() => {
window.addEventListener("pointerup", handlePointerUp); const handlePointerMove = (event: PointerEvent) => {
return () => { if (!panState.current.isPanning) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [onCanvasDeselect, onViewportChange]);
useEffect(() => { const dx = event.clientX - panState.current.startX;
const container = containerRef.current; const dy = event.clientY - panState.current.startY;
if (!container) return; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panState.current.hasMoved = true;
}
const preventWheelScroll = (event: WheelEvent) => event.preventDefault(); nextViewportRef.current = {
container.addEventListener("wheel", preventWheelScroll, { passive: false }); x: panState.current.initialX + dx,
return () => container.removeEventListener("wheel", preventWheelScroll); y: panState.current.initialY + dy,
}, [containerRef]); k: scaleRef.current,
};
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
});
};
return ( const handlePointerUp = () => {
<div if (!panState.current.isPanning) return;
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden" if (!panState.current.hasMoved) {
style={{ background: theme.canvas.background }} onCanvasDeselect?.();
onPointerDown={handlePointerDown} }
onWheel={handleWheel} panState.current.isPanning = false;
onContextMenu={onContextMenu} document.body.style.cursor = "default";
onDragOver={(event) => event.preventDefault()} };
onDrop={onDrop}
> window.addEventListener("pointermove", handlePointerMove);
<CanvasGrid viewport={viewport} mode={backgroundMode} /> window.addEventListener("pointerup", handlePointerUp);
<div return () => {
className="absolute origin-top-left" window.removeEventListener("pointermove", handlePointerMove);
style={{ window.removeEventListener("pointerup", handlePointerUp);
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`, };
}} }, [onCanvasDeselect, onViewportChange]);
>
{children} useEffect(() => {
</div> const container = containerRef.current;
</div> if (!container) return;
);
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
return (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown}
onWheel={handleWheel}
onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<CanvasGrid viewport={viewport} mode={backgroundMode} />
<div
className="absolute origin-top-left"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{children}
</div>
</div>
);
} }
function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: CanvasBackgroundMode }) { function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: CanvasBackgroundMode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (mode === "blank") return null; if (mode === "blank") return null;
const gridSize = 48 * viewport.k; const gridSize = 48 * viewport.k;
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
className="pointer-events-none absolute inset-0 opacity-40" className="pointer-events-none absolute inset-0 opacity-40"
style={{ style={{
backgroundImage, backgroundImage,
backgroundSize: `${gridSize}px ${gridSize}px`, backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${x}px ${y}px`, backgroundPosition: `${x}px ${y}px`,
}} }}
/> />
); );
} }
+20 -20
View File
@@ -2,33 +2,33 @@ import { CanvasNodeType } from "./types";
import type { CanvasNodeMetadata } from "./types"; import type { CanvasNodeMetadata } from "./types";
type CanvasNodeSpec = { type CanvasNodeSpec = {
width: number; width: number;
height: number; height: number;
title: string; title: string;
metadata?: CanvasNodeMetadata; metadata?: CanvasNodeMetadata;
}; };
export const NODE_DEFAULT_SIZE = { export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" }, [CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" }, [CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" }, [CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>; } satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
export const NODE_SPECS = { export const NODE_SPECS = {
[CanvasNodeType.Image]: { [CanvasNodeType.Image]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Image], ...NODE_DEFAULT_SIZE[CanvasNodeType.Image],
metadata: { content: "", status: "idle" }, metadata: { content: "", status: "idle" },
}, },
[CanvasNodeType.Text]: { [CanvasNodeType.Text]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Text], ...NODE_DEFAULT_SIZE[CanvasNodeType.Text],
metadata: { content: "", status: "idle", fontSize: 14 }, metadata: { content: "", status: "idle", fontSize: 14 },
}, },
[CanvasNodeType.Config]: { [CanvasNodeType.Config]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Config], ...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
metadata: { content: "", status: "idle", generationMode: "image" }, metadata: { content: "", status: "idle", generationMode: "image" },
}, },
} satisfies Record<CanvasNodeType, CanvasNodeSpec>; } satisfies Record<CanvasNodeType, CanvasNodeSpec>;
export function getNodeSpec(type: CanvasNodeType) { export function getNodeSpec(type: CanvasNodeType) {
return NODE_SPECS[type]; return NODE_SPECS[type];
} }
+78 -64
View File
@@ -11,75 +11,89 @@ import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
import { useCanvasUiStore } from "./stores/use-canvas-ui-store"; import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
type CanvasExportFile = { type CanvasExportFile = {
app: "infinite-canvas"; app: "infinite-canvas";
version: number; version: number;
exportedAt: string; exportedAt: string;
project: CanvasProject; project: CanvasProject;
}; };
export default function CanvasPage() { export default function CanvasPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const hydrated = useCanvasStore((state) => state.hydrated); const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects); const projects = useCanvasStore((state) => state.projects);
const createProject = useCanvasStore((state) => state.createProject); const createProject = useCanvasStore((state) => state.createProject);
const importProject = useCanvasStore((state) => state.importProject); const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds); const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const enterProject = (id: string) => { const enterProject = (id: string) => {
router.push(`/canvas/${id}`); router.push(`/canvas/${id}`);
}; };
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`)); const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
const importCanvas = async (file?: File) => { const importCanvas = async (file?: File) => {
if (!file) return; if (!file) return;
try { try {
const data = JSON.parse(await file.text()) as CanvasExportFile; const data = JSON.parse(await file.text()) as CanvasExportFile;
enterProject(importProject(data.project)); enterProject(importProject(data.project));
message.success("画布已导入"); message.success("画布已导入");
} catch { } catch {
message.error("导入失败,请选择有效的 JSON 文件"); message.error("导入失败,请选择有效的 JSON 文件");
} finally { } finally {
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
} }
}; };
return ( return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100"> <main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800"> <header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
<div> <div>
<p className="text-xs text-stone-500"></p> <p className="text-xs text-stone-500"></p>
<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>
</div> ) : null}
</header> {projects.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
</Button>
) : null}
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
</Button>
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
</Button>
</div>
</header>
{!hydrated ? ( {!hydrated ? (
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section> <section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section>
) : projects.length ? ( ) : projects.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => ( {projects.map((project) => (
<CanvasProjectCard key={project.id} project={project} /> <CanvasProjectCard key={project.id} project={project} />
))} ))}
</div> </div>
) : ( ) : (
<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}>
</section>
)} </Button>
</div> </section>
)}
</div>
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} /> <input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog /> <CanvasDeleteProjectsDialog />
</main> </main>
); );
} }
@@ -7,27 +7,27 @@ import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "../types"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "../types";
export type CanvasProject = { export type CanvasProject = {
id: string; id: string;
title: string; title: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
nodes: CanvasNodeData[]; nodes: CanvasNodeData[];
connections: CanvasConnection[]; connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[]; chatSessions: CanvasAssistantSession[];
activeChatId: string | null; activeChatId: string | null;
backgroundMode: CanvasBackgroundMode; backgroundMode: CanvasBackgroundMode;
viewport: ViewportTransform; viewport: ViewportTransform;
}; };
type CanvasStore = { type CanvasStore = {
hydrated: boolean; hydrated: boolean;
projects: CanvasProject[]; projects: CanvasProject[];
createProject: (title?: string) => string; createProject: (title?: string) => string;
importProject: (project: Partial<CanvasProject>) => string; importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null; openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void; renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void; deleteProjects: (ids: string[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void; updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void;
}; };
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 }; const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
@@ -37,89 +37,93 @@ let saveTimer: ReturnType<typeof setTimeout> | null = null;
let queuedPersistState: PersistedCanvasState | null = null; let queuedPersistState: PersistedCanvasState | null = null;
const canvasStorage: PersistStorage<CanvasStore> = { const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => { getItem: async (name) => {
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<CanvasStore>; const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
queuedPersistState = parsed.state as PersistedCanvasState; queuedPersistState = parsed.state as PersistedCanvasState;
return parsed; return parsed;
}, },
setItem: (name, value) => { setItem: (name, value) => {
const nextState = value.state as PersistedCanvasState; const nextState = value.state as PersistedCanvasState;
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return; if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
queuedPersistState = nextState; queuedPersistState = nextState;
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
saveTimer = null; saveTimer = null;
void localForageStorage.setItem(name, JSON.stringify(value)); void localForageStorage.setItem(name, JSON.stringify(value));
}, 400); }, 400);
}, },
removeItem: (name) => localForageStorage.removeItem(name), removeItem: (name) => localForageStorage.removeItem(name),
}; };
export const useCanvasStore = create<CanvasStore>()( export const useCanvasStore = create<CanvasStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
hydrated: false, hydrated: false,
projects: [], projects: [],
createProject: (title = "未命名画布") => { createProject: (title = "未命名画布") => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = nanoid(); const id = nanoid();
const project: CanvasProject = { const project: CanvasProject = {
id, id,
title, title,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
nodes: [], nodes: [],
connections: [], connections: [],
chatSessions: [], chatSessions: [],
activeChatId: null, activeChatId: null,
backgroundMode: "lines", backgroundMode: "lines",
viewport: initialViewport, viewport: initialViewport,
}; };
set((state) => ({ projects: [project, ...state.projects] })); set((state) => ({ projects: [project, ...state.projects] }));
return id; return id;
}, },
importProject: (source) => { importProject: (source) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const project: CanvasProject = { const project: CanvasProject = {
id: nanoid(), id: nanoid(),
title: source.title || "导入画布", title: source.title || "导入画布",
createdAt: source.createdAt || now, createdAt: source.createdAt || now,
updatedAt: now, updatedAt: now,
nodes: source.nodes || [], nodes: source.nodes || [],
connections: source.connections || [], connections: source.connections || [],
chatSessions: source.chatSessions || [], chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null, activeChatId: source.activeChatId || null,
backgroundMode: source.backgroundMode || "lines", backgroundMode: source.backgroundMode || "lines",
viewport: source.viewport || initialViewport, viewport: source.viewport || initialViewport,
}; };
set((state) => ({ projects: [project, ...state.projects] })); set((state) => ({ projects: [project, ...state.projects] }));
return project.id; return project.id;
}, },
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) => { })),
const projects = state.projects.filter((project) => !ids.includes(project.id)); deleteProjects: (ids) =>
return { projects }; set((state) => {
}), const projects = state.projects.filter((project) => !ids.includes(project.id));
updateProject: (id, patch) => set((state) => ({ return { projects };
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project), }),
})), updateProject: (id, patch) =>
}), set((state) => ({
{ projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
name: CANVAS_STORE_KEY, })),
storage: canvasStorage, }),
partialize: (state) => ({ {
projects: state.projects, name: CANVAS_STORE_KEY,
}) as StorageValue<CanvasStore>["state"], storage: canvasStorage,
onRehydrateStorage: () => () => { partialize: (state) =>
useCanvasStore.setState({ hydrated: true }); ({
}, projects: state.projects,
}, }) as StorageValue<CanvasStore>["state"],
), onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
); );
@@ -1,27 +1,27 @@
import { create } from "zustand"; import { create } from "zustand";
type CanvasUiStore = { type CanvasUiStore = {
editingProjectId: string | null; editingProjectId: string | null;
editingProjectTitle: string; editingProjectTitle: string;
selectedProjectIds: string[]; selectedProjectIds: string[];
deleteProjectIds: string[]; deleteProjectIds: string[];
startEditingProject: (id: string, title: string) => void; startEditingProject: (id: string, title: string) => void;
setEditingProjectTitle: (title: string) => void; setEditingProjectTitle: (title: string) => void;
stopEditingProject: () => void; stopEditingProject: () => void;
toggleSelectedProjectId: (id: string, selected: boolean) => void; toggleSelectedProjectId: (id: string, selected: boolean) => void;
setDeleteProjectIds: (ids: string[]) => void; setDeleteProjectIds: (ids: string[]) => void;
removeSelectedProjectIds: (ids: string[]) => void; removeSelectedProjectIds: (ids: string[]) => void;
}; };
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({ export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
editingProjectId: null, editingProjectId: null,
editingProjectTitle: "", editingProjectTitle: "",
selectedProjectIds: [], selectedProjectIds: [],
deleteProjectIds: [], deleteProjectIds: [],
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }), startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }), setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
stopEditingProject: () => set({ editingProjectId: null }), stopEditingProject: () => set({ editingProjectId: null }),
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })), toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }), setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })), removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
})); }));
+75 -76
View File
@@ -1,18 +1,18 @@
export type Position = { export type Position = {
x: number; x: number;
y: number; y: number;
}; };
export type ViewportTransform = { export type ViewportTransform = {
x: number; x: number;
y: number; y: number;
k: number; k: number;
}; };
export enum CanvasNodeType { export enum CanvasNodeType {
Image = "image", Image = "image",
Text = "text", Text = "text",
Config = "config", Config = "config",
} }
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error"; export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
@@ -20,101 +20,100 @@ export type CanvasGenerationMode = "text" | "image";
export type CanvasImageGenerationType = "generation" | "edit"; export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeMetadata = { export type CanvasNodeMetadata = {
content?: string; content?: string;
prompt?: string; prompt?: string;
status?: CanvasNodeStatus; status?: CanvasNodeStatus;
errorDetails?: string; errorDetails?: string;
fontSize?: number; fontSize?: number;
generationMode?: CanvasGenerationMode; generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType; generationType?: CanvasImageGenerationType;
model?: string; model?: string;
size?: string; size?: string;
quality?: string; quality?: string;
count?: number; count?: number;
references?: string[]; references?: string[];
naturalWidth?: number; naturalWidth?: number;
naturalHeight?: number; naturalHeight?: number;
freeResize?: boolean; freeResize?: boolean;
isBatchRoot?: boolean; isBatchRoot?: boolean;
batchRootId?: string; batchRootId?: string;
batchChildIds?: string[]; batchChildIds?: string[];
batchUsesReferenceImages?: boolean; batchUsesReferenceImages?: boolean;
primaryImageId?: string; primaryImageId?: string;
imageBatchExpanded?: boolean; imageBatchExpanded?: boolean;
inputOrder?: string[]; inputOrder?: string[];
storageKey?: string; storageKey?: string;
mimeType?: string; mimeType?: string;
bytes?: number; bytes?: number;
}; };
export type CanvasNodeData = { export type CanvasNodeData = {
id: string; id: string;
type: CanvasNodeType; type: CanvasNodeType;
title: string; title: string;
position: Position; position: Position;
width: number; width: number;
height: number; height: number;
metadata?: CanvasNodeMetadata; metadata?: CanvasNodeMetadata;
}; };
export type CanvasConnection = { export type CanvasConnection = {
id: string; id: string;
fromNodeId: string; fromNodeId: string;
toNodeId: string; toNodeId: string;
}; };
export type CanvasAssistantReference = { export type CanvasAssistantReference = {
id: string; id: string;
type: CanvasNodeType; type: CanvasNodeType;
title: string; title: string;
dataUrl?: string; dataUrl?: string;
storageKey?: string; storageKey?: string;
text?: string; text?: string;
}; };
export type CanvasAssistantImage = { export type CanvasAssistantImage = {
id: string; id: string;
dataUrl: string; dataUrl: string;
storageKey?: string; storageKey?: string;
prompt: string; prompt: string;
}; };
export type CanvasAssistantMessage = { export type CanvasAssistantMessage = {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
mode: "ask" | "image"; mode: "ask" | "image";
text: string; text: string;
isLoading?: boolean; isLoading?: boolean;
references?: CanvasAssistantReference[]; references?: CanvasAssistantReference[];
images?: CanvasAssistantImage[]; images?: CanvasAssistantImage[];
}; };
export type CanvasAssistantSession = { export type CanvasAssistantSession = {
id: string; id: string;
title: string; title: string;
messages: CanvasAssistantMessage[]; messages: CanvasAssistantMessage[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type ConnectionHandle = { export type ConnectionHandle = {
nodeId: string; nodeId: string;
handleType: "source" | "target"; handleType: "source" | "target";
}; };
export type SelectionBox = { export type SelectionBox = {
startWorldX: number; startWorldX: number;
startWorldY: number; startWorldY: number;
currentWorldX: number; currentWorldX: number;
currentWorldY: number; currentWorldY: number;
additive: boolean; additive: boolean;
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;
}; };
@@ -1,90 +1,84 @@
"use client"; "use client";
export type ImageCropRect = { export type ImageCropRect = {
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
}; };
export type ImageAngleTransform = { export type ImageAngleTransform = {
horizontalAngle: number; horizontalAngle: number;
pitchAngle: number; pitchAngle: number;
cameraDistance: number; cameraDistance: number;
wideAngle: boolean; wideAngle: boolean;
}; };
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), const size = Math.min(image.width, image.height);
Math.floor(crop.y * image.height), const sx = Math.max(0, Math.floor((image.width - size) / 2));
Math.ceil(crop.width * image.width), const sy = Math.max(0, Math.floor((image.height - size) / 2));
Math.ceil(crop.height * image.height), return drawCrop(image, sx, sy, size, size);
);
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
} }
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) { export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl); const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18); const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2; canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2; canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!context) return dataUrl; if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
const horizontal = params.horizontalAngle / 60; const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45; const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035; const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1; const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale)); const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28); const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18); const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2; const cx = canvas.width / 2;
const cy = canvas.height / 2; const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18; const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12; const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5; const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45; const y = cy - height / 2 + pitch * padding * 0.45;
context.save(); context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0); context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height); context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore(); context.restore();
if (params.wideAngle) { if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62); const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)"); gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)"); gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient; context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height); context.fillRect(0, 0, canvas.width, canvas.height);
} }
return canvas.toDataURL("image/png"); return canvas.toDataURL("image/png");
} }
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) { function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw); canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh); canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!context) return image.src; if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png"); return canvas.toDataURL("image/png");
} }
function loadImage(dataUrl: string) { function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve) => { return new Promise<HTMLImageElement>((resolve) => {
const image = new Image(); const image = new Image();
image.onload = () => resolve(image); image.onload = () => resolve(image);
image.src = dataUrl; image.src = dataUrl;
}); });
} }
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -5,10 +5,10 @@ import type { ReactNode } from "react";
import { AppTopNav } from "@/components/layout/app-top-nav"; import { AppTopNav } from "@/components/layout/app-top-nav";
export default function UserLayout({ children }: { children: ReactNode }) { export default function UserLayout({ children }: { children: ReactNode }) {
return ( return (
<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">
<AppTopNav /> <AppTopNav />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div> <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</div> </div>
); );
} }
+54 -54
View File
@@ -8,67 +8,67 @@ import { Suspense } from "react";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
type LoginFormValues = { type LoginFormValues = {
username: string; username: string;
password: string; password: string;
confirmPassword?: string; confirmPassword?: string;
}; };
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<LoginContent /> <LoginContent />
</Suspense> </Suspense>
); );
} }
function LoginContent() { function LoginContent() {
const { message } = App.useApp(); const { message } = App.useApp();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const login = useUserStore((state) => state.login); const login = useUserStore((state) => state.login);
const isLoading = useUserStore((state) => state.isLoading); const isLoading = useUserStore((state) => state.isLoading);
const redirect = searchParams.get("redirect") || "/"; const redirect = searchParams.get("redirect") || "/";
const submit = async (values: LoginFormValues) => { const submit = async (values: LoginFormValues) => {
try { try {
const user = await login({ username: values.username, password: values.password }); const user = await login({ username: values.username, password: values.password });
message.success("登录成功"); message.success("登录成功");
router.replace(redirect.startsWith("/") ? redirect : "/"); router.replace(redirect.startsWith("/") ? redirect : "/");
router.refresh(); router.refresh();
if (user.role !== "admin") router.replace("/"); if (user.role !== "admin") router.replace("/");
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "登录失败"); message.error(error instanceof Error ? error.message : "登录失败");
} }
}; };
return ( return (
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"> <main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<section className="w-full max-w-[420px]"> <section className="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 text-center">
<span <span
className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100" className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100"
style={{ style={{
mask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat",
}} }}
aria-label="无限画布" aria-label="无限画布"
/> />
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100"></h1> <h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p> <p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p>
</div> </div>
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}> <Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入用户名" }]}> <Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入用户名" }]}>
<Input prefix={<UserOutlined />} autoComplete="username" /> <Input prefix={<UserOutlined />} autoComplete="username" />
</Form.Item> </Form.Item>
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入密码" }]}> <Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200"></span>} rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" /> <Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
</Form.Item> </Form.Item>
<Button block type="primary" htmlType="submit" loading={isLoading}> <Button block type="primary" htmlType="submit" loading={isLoading}>
</Button> </Button>
</Form> </Form>
</section> </section>
</main> </main>
); );
} }
+107 -107
View File
@@ -8,118 +8,118 @@ 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, return (
color, <span className="relative inline-block px-1">
children, {action === "highlight" ? (
}: { <span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
action: "highlight" | "underline"; ) : (
color: string; <span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
children: ReactNode; )}
}) { <span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
return ( </span>
<span className="relative inline-block px-1"> );
{action === "highlight" ? (
<span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
) : (
<span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
)}
<span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
</span>
);
} }
export default function IndexPage() { export default function IndexPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const [primaryTool] = navigationTools; const [primaryTool] = navigationTools;
const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]); const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]);
const [previewIndex, setPreviewIndex] = useState(0); const [previewIndex, setPreviewIndex] = useState(0);
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 })
}, [message]); .then((data) => setPromptShowcase(data.items))
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
}, [message]);
return ( return (
<main className="relative h-full overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] text-stone-950 dark:bg-[radial-gradient(rgba(245,245,244,.18)_1px,transparent_1px)] dark:text-stone-100"> <main className="relative h-full overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] text-stone-950 dark:bg-[radial-gradient(rgba(245,245,244,.18)_1px,transparent_1px)] dark:text-stone-100">
<section className="relative mx-auto min-h-[calc(100vh-4rem)] max-w-7xl overflow-hidden px-6"> <section className="relative mx-auto min-h-[calc(100vh-4rem)] max-w-7xl overflow-hidden px-6">
<div className="pointer-events-none absolute left-[15%] top-24 size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" /> <div className="pointer-events-none absolute left-[15%] top-24 size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" /> <div className="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>
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
</h1>
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400"> <Highlighter action="underline" color="#FF9800">
<Highlighter action="underline" color="#FF9800"></Highlighter><Highlighter action="highlight" color="#87CEFA"></Highlighter>
</p> </Highlighter>
<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"> <Highlighter action="highlight" color="#87CEFA">
使
</Button> </Highlighter>
<Button size="large" href="/canvas">
</p>
</Button> <div className="mt-10 flex flex-wrap items-center justify-center gap-3">
</div> <Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
</div> 使
</Button>
<section className="relative mx-auto mb-20 max-w-6xl border-t border-stone-200 pt-12 dark:border-stone-800"> <Button size="large" href="/canvas">
<div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
<div /> </Button>
<div className="max-w-2xl text-center"> </div>
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h2>
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">
</p>
</div>
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
</Button>
</div>
<div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
{promptShowcase.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => {
setPreviewIndex(index);
setPreviewOpen(true);
}}
className={cn(
"group relative cursor-pointer overflow-hidden border border-stone-200 bg-stone-100 text-left dark:border-stone-800 dark:bg-stone-900",
index === 0 && "md:col-span-2 md:row-span-2",
index === 3 && "md:col-span-2",
)}
>
<img src={item.coverUrl} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent p-4 text-white">
<div className="mb-2 flex flex-wrap gap-1.5">
{item.tags.slice(0, 2).map((tag) => (
<Tag key={tag} variant="filled" className="m-0 bg-white/15 text-[11px] text-white backdrop-blur">
{tag}
</Tag>
))}
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-white/75">{item.prompt}</p>
</div> </div>
</button>
))} <section className="relative mx-auto mb-20 max-w-6xl border-t border-stone-200 pt-12 dark:border-stone-800">
</div> <div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
</section> <div />
</section> <div className="max-w-2xl text-center">
<Image.PreviewGroup <h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h2>
preview={{ <p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400"></p>
open: previewOpen, </div>
current: previewIndex, <Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
onOpenChange: setPreviewOpen,
onChange: setPreviewIndex, </Button>
}} </div>
> <div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
<div className="hidden"> {promptShowcase.map((item, index) => (
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)} <button
</div> key={item.id}
</Image.PreviewGroup> type="button"
</main> onClick={() => {
); setPreviewIndex(index);
setPreviewOpen(true);
}}
className={cn(
"group relative cursor-pointer overflow-hidden border border-stone-200 bg-stone-100 text-left dark:border-stone-800 dark:bg-stone-900",
index === 0 && "md:col-span-2 md:row-span-2",
index === 3 && "md:col-span-2",
)}
>
<img src={item.coverUrl} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent p-4 text-white">
<div className="mb-2 flex flex-wrap gap-1.5">
{item.tags.slice(0, 2).map((tag) => (
<Tag key={tag} variant="filled" className="m-0 bg-white/15 text-[11px] text-white backdrop-blur">
{tag}
</Tag>
))}
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-white/75">{item.prompt}</p>
</div>
</button>
))}
</div>
</section>
</section>
<Image.PreviewGroup
preview={{
open: previewOpen,
current: previewIndex,
onOpenChange: setPreviewOpen,
onChange: setPreviewIndex,
}}
>
<div className="hidden">
{promptShowcase.map((item) => (
<Image key={item.id} src={item.coverUrl} alt={item.title} />
))}
</div>
</Image.PreviewGroup>
</main>
);
} }
+105 -81
View File
@@ -13,92 +13,116 @@ import { useAssetStore } from "@/stores/use-asset-store";
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts"; import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
export default function PromptsPage() { export default function PromptsPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const [titleKeyword, setTitleKeyword] = useState(""); const [titleKeyword, setTitleKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null); const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const addAsset = useAssetStore((state) => state.addAsset); const addAsset = useAssetStore((state) => state.addAsset);
const copyText = useCopyText(); const copyText = useCopyText();
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory }); const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => { useEffect(() => {
if (query.isError) { if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
} }
}, [message, query.error, query.isError]); }, [message, query.error, query.isError]);
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) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } }); addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的素材"); message.success("已加入我的素材");
}; };
const handleListScroll = (event: UIEvent<HTMLDivElement>) => { const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) { if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
void query.fetchNextPage(); void query.fetchNextPage();
} }
}; };
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
<div className="pb-8"> className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
<div className="mx-auto max-w-5xl text-center"> onScroll={handleListScroll}
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1> >
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p> <div className="pb-8">
</div> <div className="mx-auto max-w-5xl text-center">
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null} <h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
{!query.isLoading ? ( <p className="mt-3 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
<> </div>
<div className="mx-auto mt-8 w-full max-w-2xl"> {query.isLoading ? (
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} /> <div className="flex h-60 items-center justify-center">
</div> <Spin />
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left"> </div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"> ) : null}
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div> {!query.isLoading ? (
<div className="flex flex-wrap gap-2"> <>
{promptCategoryOptions.map((category) => ( <div className="mx-auto mt-8 w-full max-w-2xl">
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}> <Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
{category} </div>
</Tag.CheckableTag> <div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
))} <div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
</div> <div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategoryOptions.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
onChange={() => toggleTag(tag)}
>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</>
) : null}
</div> </div>
<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> {!query.isLoading ? (
<div className="flex flex-wrap gap-2"> <div>
{promptTags.map((tag) => ( <div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<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)}> {promptItems.map((item) => (
{tag} <PromptCard
</Tag.CheckableTag> key={item.id}
))} item={item}
</div> onOpen={() => setSelectedPrompt(item)}
</div> onCopy={() => copyText(item.prompt, "提示词已复制")}
</div> extraAction={
</> <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
) : null}
</Button>
}
/>
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
</div>
</div>
) : null}
</main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</div> </div>
);
{!query.isLoading ? (
<div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{promptItems.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => copyText(item.prompt, "提示词已复制")} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} />
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</div>
) : null}
</main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</div>
);
} }
+31 -31
View File
@@ -4,48 +4,48 @@ export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
type RouteContext = { type RouteContext = {
params: Promise<{ path: string[] }>; params: Promise<{ path: string[] }>;
}; };
function proxyHeaders(request: NextRequest) { function proxyHeaders(request: NextRequest) {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.delete("host"); headers.delete("host");
headers.delete("content-length"); headers.delete("content-length");
headers.delete("connection"); headers.delete("connection");
return headers; return headers;
} }
function responseHeaders(response: Response) { function responseHeaders(response: Response) {
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
headers.delete("content-length"); headers.delete("content-length");
headers.delete("content-encoding"); headers.delete("content-encoding");
headers.delete("transfer-encoding"); headers.delete("transfer-encoding");
return headers; return headers;
} }
async function proxy(request: NextRequest, context: RouteContext) { async function proxy(request: NextRequest, context: RouteContext) {
const { path } = await context.params; const { path } = await context.params;
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080"; const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`; const target = `${apiBaseUrl.replace(/\/$/, "")}/api/${path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`;
const hasBody = request.method !== "GET" && request.method !== "HEAD"; const hasBody = request.method !== "GET" && request.method !== "HEAD";
try { try {
const response = await fetch(target, { const response = await fetch(target, {
method: request.method, method: request.method,
headers: proxyHeaders(request), headers: proxyHeaders(request),
body: hasBody ? request.body : undefined, body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined, duplex: hasBody ? "half" : undefined,
} as RequestInit & { duplex?: "half" }); } as RequestInit & { duplex?: "half" });
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: responseHeaders(response), headers: responseHeaders(response),
}); });
} catch (error) { } catch (error) {
console.error("Failed to proxy", target, error); console.error("Failed to proxy", target, error);
return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 }); return Response.json({ code: 1, data: null, msg: "接口连接失败,请确认后端服务已启动" }, { status: 502 });
} }
} }
export const GET = proxy; export const GET = proxy;
+435 -373
View File
@@ -5,401 +5,463 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-border: var(--border); --color-border: var(--border);
--color-ring: var(--ring); --color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-input: var(--input); --color-input: var(--input);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html, html,
body { body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
textarea { textarea {
resize: none; resize: none;
} }
textarea::-webkit-resizer { textarea::-webkit-resizer {
display: none; display: none;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
@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; color: transparent;
color: transparent; animation: ai-title-aurora 7s ease-in-out infinite;
animation: ai-title-aurora 7s ease-in-out infinite;
}
.dark .ai-title-aurora {
background:
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
}
@keyframes ai-title-aurora {
0%, 100% {
background-position: 0% 50%;
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
}
50% {
background-position: 100% 50%;
filter: drop-shadow(0 12px 28px rgba(28, 25, 23, 0.08));
}
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.prompt-filter-tag.ant-tag-checkable {
margin: 0;
border: 1px solid transparent;
border-radius: 6px;
padding: 5px 10px;
color: #57534e;
font-size: 14px;
line-height: 1;
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(28, 25, 23, 0.06);
color: #1c1917;
}
.prompt-filter-tag.ant-tag-checkable.is-active,
.prompt-filter-tag.ant-tag-checkable-checked {
background: #111111;
border-color: #111111;
color: #ffffff;
font-weight: 600;
}
.dark .prompt-filter-tag.ant-tag-checkable {
color: #e7e5e4;
}
.dark .prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.dark .prompt-filter-tag.ant-tag-checkable.is-active,
.dark .prompt-filter-tag.ant-tag-checkable-checked {
background: rgba(250, 250, 249, 0.96);
border-color: rgba(255, 255, 255, 0.88);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 18px rgba(0, 0, 0, 0.22);
color: #0c0a09 !important;
font-weight: 700;
}
.thin-scrollbar {
scrollbar-color: rgba(120, 113, 108, 0.45) transparent;
scrollbar-width: thin;
}
.thin-scrollbar::-webkit-scrollbar {
height: 4px;
width: 4px;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.45);
border-radius: 999px;
}
.hover-scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.hover-scrollbar:hover {
scrollbar-color: rgba(120, 113, 108, 0.5) transparent;
}
.hover-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.hover-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-track {
background: rgba(120, 113, 108, 0.12);
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.5);
}
.hover-scrollbar-hint {
position: relative;
}
.hover-scrollbar-hint::after {
background: rgba(120, 113, 108, 0.42);
border-radius: 999px;
bottom: 4px;
content: "";
height: 3px;
left: 12px;
opacity: 0;
pointer-events: none;
position: absolute;
right: 12px;
transition: opacity 0.18s ease;
}
.hover-scrollbar-hint:hover::after {
opacity: 1;
}
.canvas-control-select.ant-select .ant-select-selector,
.canvas-control-number.ant-input-number {
border-color: rgba(120, 113, 108, 0.32) !important;
box-shadow: none !important;
}
.canvas-compact-control.canvas-control-select.ant-select {
height: 100%;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selector,
.canvas-compact-control.canvas-control-number.ant-input-number {
height: 100% !important;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder {
align-items: center;
display: flex;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-search-input,
.canvas-compact-control.canvas-control-number.ant-input-number .ant-input-number-input {
height: 100% !important;
line-height: 1 !important;
}
.canvas-control-select.ant-select-focused .ant-select-selector,
.canvas-control-number.ant-input-number-focused {
border-color: #a8a29e !important;
}
.dark .canvas-control-select.ant-select .ant-select-selector,
.dark .canvas-control-number.ant-input-number {
border-color: rgba(214, 211, 209, 0.2) !important;
}
.dark .canvas-control-select.ant-select-focused .ant-select-selector,
.dark .canvas-control-number.ant-input-number-focused {
border-color: #78716c !important;
}
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
align-items: center;
display: flex;
height: 30px;
justify-content: center;
padding-inline: 9px;
}
.canvas-image-settings-popover .ant-popover-inner {
border-radius: 18px;
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
padding: 18px;
}
.canvas-image-settings-popover .ant-segmented {
border-radius: 999px;
padding: 3px;
}
.canvas-image-settings-popover .ant-segmented-item {
border-radius: 999px;
}
.dark .canvas-image-settings-popover .ant-popover-inner {
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.45);
}
.canvas-node-info-modal .ant-modal-header {
padding-right: 0;
}
.canvas-node-info-modal .ant-modal-title {
line-height: 1;
}
.canvas-node-info-modal .ant-modal-close {
top: 21px;
}
@keyframes canvas-batch-child-in {
from {
opacity: 0;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
} }
72% { .dark .ai-title-aurora {
opacity: 1; background: linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
transform: translate(0, 0) rotate(-1deg) scale(1.015); background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
} }
to { @keyframes ai-title-aurora {
opacity: 1; 0%,
transform: translate(0, 0) rotate(0) scale(1); 100% {
} background-position: 0% 50%;
} filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
}
@keyframes canvas-batch-child-out { 50% {
from { background-position: 100% 50%;
opacity: 1; filter: drop-shadow(0 12px 28px rgba(28, 25, 23, 0.08));
transform: translate(0, 0) rotate(0) scale(1); }
} }
to { .hide-scrollbar {
opacity: 0; -ms-overflow-style: none;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72); scrollbar-width: none;
} }
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.prompt-filter-tag.ant-tag-checkable {
margin: 0;
border: 1px solid transparent;
border-radius: 6px;
padding: 5px 10px;
color: #57534e;
font-size: 14px;
line-height: 1;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease,
color 0.15s ease;
}
.prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(28, 25, 23, 0.06);
color: #1c1917;
}
.prompt-filter-tag.ant-tag-checkable.is-active,
.prompt-filter-tag.ant-tag-checkable-checked {
background: #111111;
border-color: #111111;
color: #ffffff;
font-weight: 600;
}
.dark .prompt-filter-tag.ant-tag-checkable {
color: #e7e5e4;
}
.dark .prompt-filter-tag.ant-tag-checkable:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.dark .prompt-filter-tag.ant-tag-checkable.is-active,
.dark .prompt-filter-tag.ant-tag-checkable-checked {
background: rgba(250, 250, 249, 0.96);
border-color: rgba(255, 255, 255, 0.88);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.18),
0 6px 18px rgba(0, 0, 0, 0.22);
color: #0c0a09 !important;
font-weight: 700;
}
.thin-scrollbar {
scrollbar-color: rgba(120, 113, 108, 0.45) transparent;
scrollbar-width: thin;
}
.thin-scrollbar::-webkit-scrollbar {
height: 4px;
width: 4px;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.45);
border-radius: 999px;
}
.hover-scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.hover-scrollbar:hover {
scrollbar-color: rgba(120, 113, 108, 0.5) transparent;
}
.hover-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.hover-scrollbar::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-track {
background: rgba(120, 113, 108, 0.12);
border-radius: 999px;
}
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(120, 113, 108, 0.5);
}
.hover-scrollbar-hint {
position: relative;
}
.hover-scrollbar-hint::after {
background: rgba(120, 113, 108, 0.42);
border-radius: 999px;
bottom: 4px;
content: "";
height: 3px;
left: 12px;
opacity: 0;
pointer-events: none;
position: absolute;
right: 12px;
transition: opacity 0.18s ease;
}
.hover-scrollbar-hint:hover::after {
opacity: 1;
}
.canvas-control-select.ant-select .ant-select-selector,
.canvas-control-number.ant-input-number {
border-color: rgba(120, 113, 108, 0.32) !important;
box-shadow: none !important;
}
.canvas-compact-control.canvas-control-select.ant-select {
height: 100%;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selector,
.canvas-compact-control.canvas-control-number.ant-input-number {
height: 100% !important;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder {
align-items: center;
display: flex;
}
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder,
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-search-input,
.canvas-compact-control.canvas-control-number.ant-input-number .ant-input-number-input {
height: 100% !important;
line-height: 1 !important;
}
.canvas-composer-icon.ant-btn {
flex: 0 0 32px;
width: 32px;
}
.canvas-composer-tools {
container-type: inline-size;
}
.canvas-composer-mode-button {
width: 32px;
}
.canvas-composer-tools .canvas-composer-mode-button span,
.canvas-composer-tools .canvas-model-picker-text,
.canvas-composer-tools .canvas-select-chevron,
.canvas-composer-tools .canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
display: none;
}
.canvas-composer-tools .canvas-composer-model-picker {
width: 32px;
min-width: 32px;
justify-content: center;
gap: 0;
padding-inline: 0;
}
@container (min-width: 440px) {
.canvas-composer-mode-button {
padding-inline: 8px;
width: auto;
}
.canvas-composer-tools .canvas-composer-mode-button span {
display: inline;
font-size: 12px;
line-height: 1;
}
.canvas-composer-icon.ant-btn {
flex: 0 1 auto;
max-width: 170px;
width: auto !important;
}
.canvas-composer-tools .canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
display: inline-flex;
min-width: 0;
}
.canvas-composer-tools .canvas-composer-model-picker {
width: auto;
min-width: 9rem;
justify-content: flex-start;
gap: 0.5rem;
padding-inline: 0.75rem;
}
.canvas-composer-tools .canvas-model-picker-text,
.canvas-composer-tools .canvas-select-chevron {
display: inline;
}
}
.canvas-control-select.ant-select-focused .ant-select-selector,
.canvas-control-number.ant-input-number-focused {
border-color: #a8a29e !important;
}
.dark .canvas-control-select.ant-select .ant-select-selector,
.dark .canvas-control-number.ant-input-number {
border-color: rgba(214, 211, 209, 0.2) !important;
}
.dark .canvas-control-select.ant-select-focused .ant-select-selector,
.dark .canvas-control-number.ant-input-number-focused {
border-color: #78716c !important;
}
.canvas-image-settings-popover .ant-popover-inner {
border-radius: 18px;
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
padding: 18px;
}
.canvas-image-settings-popover .ant-segmented {
border-radius: 999px;
padding: 3px;
}
.canvas-image-settings-popover .ant-segmented-item {
border-radius: 999px;
}
.dark .canvas-image-settings-popover .ant-popover-inner {
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.45);
}
.canvas-node-info-modal .ant-modal-header {
padding-right: 0;
}
.canvas-node-info-modal .ant-modal-title {
line-height: 1;
}
.canvas-node-info-modal .ant-modal-close {
top: 21px;
}
@keyframes canvas-batch-child-in {
from {
opacity: 0;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
}
72% {
opacity: 1;
transform: translate(0, 0) rotate(-1deg) scale(1.015);
}
to {
opacity: 1;
transform: translate(0, 0) rotate(0) scale(1);
}
}
@keyframes canvas-batch-child-out {
from {
opacity: 1;
transform: translate(0, 0) rotate(0) scale(1);
}
to {
opacity: 0;
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
}
}
} }
::view-transition-old(root), ::view-transition-new(root) { ::view-transition-old(root),
::view-transition-new(root) {
animation: none; animation: none;
mix-blend-mode: normal; mix-blend-mode: normal;
} }
+25 -26
View File
@@ -7,35 +7,34 @@ import "./globals.css";
import React from "react"; import React from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "无限画布", title: "无限画布",
description: "一个无限画布创作工具", description: "一个无限画布创作工具",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="zh-CN" suppressHydrationWarning className="font-sans"> <html lang="zh-CN" suppressHydrationWarning className="font-sans">
<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 id="theme-script"
id="theme-script" strategy="beforeInteractive"
strategy="beforeInteractive" dangerouslySetInnerHTML={{
dangerouslySetInnerHTML={{ __html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`,
__html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`, }}
}} />
/> <AntdRegistry>
<AntdRegistry> <AppProviders>{children}</AppProviders>
<AppProviders>{children}</AppProviders> </AntdRegistry>
</AntdRegistry> </body>
</body> </html>
</html> );
);
} }
+24 -25
View File
@@ -2,29 +2,28 @@ import { Home, LogIn } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export default function NotFound() { export default function NotFound() {
return ( return (
<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 <h1 className="text-3xl font-semibold tracking-normal"></h1>
</div> <p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400"></p>
<h1 className="text-3xl font-semibold tracking-normal"></h1> <div className="mt-8 flex flex-wrap justify-center gap-3">
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400"> <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" />
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3"> </Link>
<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
<Home className="size-4" /> 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> >
<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> </div>
</div> </section>
</section> </main>
</main> </div>
</div> );
);
} }
+31 -31
View File
@@ -9,39 +9,39 @@ import { cn } from "@/lib/utils";
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"]; const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) { export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 1000); const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
const index = Math.floor(tick / 2) % pendingMessages.length; const index = Math.floor(tick / 2) % pendingMessages.length;
const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88); const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88);
return ( return (
<div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}> <div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}>
<div <div
className="absolute inset-0 opacity-60" className="absolute inset-0 opacity-60"
style={{ style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)", backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px", backgroundSize: "16px 16px",
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)", maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
}} }}
/> />
<div className="absolute left-4 top-4 flex items-center gap-2 text-[15px] font-medium text-stone-500 dark:text-stone-300"> <div className="absolute left-4 top-4 flex items-center gap-2 text-[15px] font-medium text-stone-500 dark:text-stone-300">
<LoaderCircle className="size-4 animate-spin" /> <LoaderCircle className="size-4 animate-spin" />
<span>{label || pendingMessages[index]}</span> <span>{label || pendingMessages[index]}</span>
</div> </div>
<div className="absolute bottom-4 left-4 right-4"> <div className="absolute bottom-4 left-4 right-4">
<div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400"> <div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400">
<span>{formatDuration(tick * 1000)}</span> <span>{formatDuration(tick * 1000)}</span>
<span>{Math.floor(progress)}%</span> <span>{Math.floor(progress)}%</span>
</div>
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12">
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
</div>
</div>
</div> </div>
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12"> );
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
);
} }
+116 -102
View File
@@ -8,111 +8,125 @@ import { fetchImageModels } from "@/services/api/image";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
export function AppConfigModal() { export function AppConfigModal() {
const { message } = App.useApp(); const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false); const [loadingModels, setLoadingModels] = useState(false);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig); const updateConfig = useConfigStore((state) => state.updateConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const publicSettings = useConfigStore((state) => state.publicSettings); const publicSettings = useConfigStore((state) => state.publicSettings);
const effectiveConfig = useEffectiveConfig(); const effectiveConfig = useEffectiveConfig();
const modelChannel = publicSettings?.modelChannel; const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true; const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote"; const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config; const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const finishConfig = () => { const finishConfig = () => {
setConfigDialogOpen(false); setConfigDialogOpen(false);
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return; if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return; if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote"); if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存"); message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue(); clearPromptContinue();
}; };
const refreshModels = async () => { const refreshModels = async () => {
if (effectiveMode === "remote") return; if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) { if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key"); message.error("请先填写 Base URL 和 API Key");
return; return;
} }
setLoadingModels(true); setLoadingModels(true);
try { try {
const models = await fetchImageModels(config); const models = await fetchImageModels(config);
updateConfig("models", models); updateConfig("models", models);
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]); if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]); if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
message.success("模型列表已更新"); message.success("模型列表已更新");
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败"); message.error(error instanceof Error ? error.message : "读取模型失败");
} finally { } finally {
setLoadingModels(false); setLoadingModels(false);
} }
}; };
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={
open={isConfigOpen} <div>
width={760} <div className="text-lg font-semibold"></div>
centered <div className="mt-1 text-xs font-normal text-stone-500"></div>
onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" onClick={finishConfig}></Button>}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
/>
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div> </div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}></Button> }
</div> open={isConfigOpen}
</> width={760}
) : ( centered
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800"> onCancel={() => setConfigDialogOpen(false)}
<div className="font-medium text-stone-900 dark:text-stone-100"></div> footer={
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div> <Button type="primary" onClick={finishConfig}>
</Button>
}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[
{ label: "本地直连", value: "local" },
{ label: "云端渠道", value: "remote" },
]}
/>
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div>
</>
) : (
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100"></div>
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
</Form.Item>
</div>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
</Form>
</div> </div>
)} </Modal>
<div className="grid gap-4 md:grid-cols-2"> );
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
</Form.Item>
</div>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
</Form>
</div>
</Modal>
);
} }
+23 -23
View File
@@ -12,33 +12,33 @@ import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, staleTime: 30_000,
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
},
}, },
},
}); });
export function AppProviders({ children }: { children: ReactNode }) { export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark"; const dark = theme === "dark";
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark); document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
}, [dark, theme]); }, [dark, theme]);
return ( return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}> <ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}> <ProConfigProvider dark={dark}>
<App> <App>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ClientRootInit>{children}</ClientRootInit> <ClientRootInit>{children}</ClientRootInit>
</QueryClientProvider> </QueryClientProvider>
</App> </App>
</ProConfigProvider> </ProConfigProvider>
</ConfigProvider> </ConfigProvider>
); );
} }
+93 -97
View File
@@ -18,106 +18,102 @@ import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
export function AppTopNav() { export function AppTopNav() {
const pathname = usePathname(); const pathname = usePathname();
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
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);
const isReady = useUserStore((state) => state.isReady); const isReady = useUserStore((state) => state.isReady);
const hideHeader = /^\/canvas\/[^/]+/.test(pathname); const hideHeader = /^\/canvas\/[^/]+/.test(pathname);
const slug = pathname.split("/").filter(Boolean)[0]; const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
return ( return (
<> <>
{!hideHeader ? ( {!hideHeader ? (
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800"> <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="/" <span
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" className="size-5 shrink-0 bg-current"
> style={{
<span mask: "url(/logo.svg) center / contain no-repeat",
className="size-5 shrink-0 bg-current" WebkitMask: "url(/logo.svg) center / contain no-repeat",
style={{ }}
mask: "url(/logo.svg) center / contain no-repeat", />
WebkitMask: "url(/logo.svg) center / contain no-repeat", <span className="text-base font-medium"></span>
}} </Link>
/>
<span className="text-base font-medium"></span>
</Link>
<button <button
type="button" type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white" className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)} onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单" aria-label="打开导航菜单"
title="导航菜单" title="导航菜单"
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex"> <nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
{navigationTools.map((tool) => { {navigationTools.map((tool) => {
const Icon = tool.icon; const Icon = tool.icon;
const active = tool.slug === activeToolSlug; const active = tool.slug === activeToolSlug;
return ( return (
<Link <Link
key={tool.slug} key={tool.slug}
href={`/${tool.slug}`} href={`/${tool.slug}`}
className={cn( className={cn(
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px", "relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
active active
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100" ? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100", : "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
)} )}
> >
<Icon className="size-4" /> <Icon className="size-4" />
<span className="truncate">{tool.label}</span> <span className="truncate">{tool.label}</span>
</Link> </Link>
); );
})} })}
</nav> </nav>
</div> </div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap"> <div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
{isReady && user ? ( {isReady && user ? (
<UserStatusActions /> <UserStatusActions />
) : ( ) : (
<> <>
<button <button
type="button" type="button"
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4" className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
onClick={() => openConfigDialog(false)} onClick={() => openConfigDialog(false)}
aria-label="配置" aria-label="配置"
title="配置" title="配置"
> >
<Settings2 className="size-4" /> <Settings2 className="size-4" />
</button> </button>
<AnimatedThemeToggler <AnimatedThemeToggler
theme={theme} theme={theme}
onThemeChange={setTheme} onThemeChange={setTheme}
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4" className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/> />
<VersionReleaseModal /> <VersionReleaseModal />
<GitHubLink /> <GitHubLink />
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100"> <Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
</header> </header>
) : null} ) : null}
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} /> <MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
<AppConfigModal /> <AppConfigModal />
</>
</> );
);
} }
+11 -11
View File
@@ -8,18 +8,18 @@ import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) { export function ClientRootInit({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser); const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings); const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const isLoginPage = pathname === "/login" || pathname === "/admin/login"; const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => { useEffect(() => {
void loadPublicSettings(); void loadPublicSettings();
}, [loadPublicSettings]); }, [loadPublicSettings]);
useEffect(() => { useEffect(() => {
if (!isLoginPage) void hydrateUser(); if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]); }, [hydrateUser, isLoginPage]);
return <>{children}</>; return <>{children}</>;
} }
+15 -15
View File
@@ -5,22 +5,22 @@ import { GithubOutlined } from "@ant-design/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type GitHubLinkProps = { type GitHubLinkProps = {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
export function GitHubLink({ className, style }: GitHubLinkProps) { export function GitHubLink({ className, style }: GitHubLinkProps) {
return ( return (
<a <a
className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)} className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)}
style={style} style={style}
href="https://github.com/basketikun/infinite-canvas" href="https://github.com/basketikun/infinite-canvas"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
aria-label="GitHub" aria-label="GitHub"
title="GitHub" title="GitHub"
> >
<GithubOutlined className="text-base" /> <GithubOutlined className="text-base" />
</a> </a>
); );
} }
+27 -29
View File
@@ -7,36 +7,34 @@ import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type MobileNavDrawerProps = { type MobileNavDrawerProps = {
open: boolean; open: boolean;
activeToolSlug?: NavigationToolSlug; activeToolSlug?: NavigationToolSlug;
onClose: () => void; onClose: () => void;
}; };
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) { export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
return ( return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden"> <Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1"> <div className="space-y-1">
{navigationTools.map((tool) => { {navigationTools.map((tool) => {
const Icon = tool.icon; const Icon = tool.icon;
const active = tool.slug === activeToolSlug; const active = tool.slug === activeToolSlug;
return ( return (
<Link <Link
key={tool.slug} key={tool.slug}
href={`/${tool.slug}`} href={`/${tool.slug}`}
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" />
> <span>{tool.label}</span>
<Icon className="size-5" /> </Link>
<span>{tool.label}</span> );
</Link> })}
); </div>
})} </Drawer>
</div> );
</Drawer>
);
} }
@@ -16,90 +16,60 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store"; import { useUserStore } from "@/stores/use-user-store";
type UserStatusActionsProps = { type UserStatusActionsProps = {
showConfig?: boolean; showConfig?: boolean;
variant?: "default" | "canvas"; variant?: "default" | "canvas";
onOpenShortcuts?: () => void; onOpenShortcuts?: () => void;
accountOpen?: boolean; accountOpen?: boolean;
onAccountOpenChange?: (open: boolean) => void; onAccountOpenChange?: (open: boolean) => void;
accountRef?: RefObject<HTMLDivElement | null>; accountRef?: RefObject<HTMLDivElement | null>;
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, const theme = useThemeStore((state) => state.theme);
variant = "default", const setTheme = useThemeStore((state) => state.setTheme);
onOpenShortcuts, const user = useUserStore((state) => state.user);
accountOpen, const logout = useUserStore((state) => state.clearSession);
onAccountOpenChange, const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
accountRef, const canvasTheme = canvasThemes[theme];
getPopupContainer, const userName = user?.username || "用户";
}: UserStatusActionsProps) { const avatarText = (userName.trim()[0] || "U").toUpperCase();
const theme = useThemeStore((state) => state.theme); const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
const setTheme = useThemeStore((state) => state.setTheme); const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const user = useUserStore((state) => state.user); const versionStyle = iconStyle;
const logout = useUserStore((state) => state.clearSession); const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const gitHubStyle = iconStyle;
const canvasTheme = canvasThemes[theme]; const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
const userName = user?.username || "用户"; const menuItems: ItemType[] = [
const avatarText = (userName.trim()[0] || "U").toUpperCase(); { key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"; ...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined; ...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
const versionStyle = iconStyle; { type: "divider" },
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined; { key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
const gitHubStyle = iconStyle; ];
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
];
return ( 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" <Settings2 className="size-4" />
className={naturalIconClass} </button>
style={iconStyle} ) : null}
onClick={() => openConfigDialog(false)} <AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
aria-label="配置" <VersionReleaseModal style={versionStyle} />
title="配置" <GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
> <div ref={accountRef}>
<Settings2 className="size-4" /> <Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
</button> <button
) : null} type="button"
<AnimatedThemeToggler 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"
theme={theme} style={avatarStyle}
onThemeChange={setTheme} aria-label="账户菜单"
className={naturalIconClass} >
style={iconStyle} <span className="leading-none">{avatarText}</span>
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} </button>
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} </Dropdown>
/> </div>
<VersionReleaseModal style={versionStyle} /> </div>
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} /> );
<div ref={accountRef}>
<Dropdown
open={accountOpen}
onOpenChange={onAccountOpenChange}
trigger={["click"]}
placement="bottomRight"
getPopupContainer={getPopupContainer}
styles={{ root: { minWidth: 150 } }}
menu={{ items: menuItems }}
>
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{avatarText}</span>
</button>
</Dropdown>
</div>
</div>
);
} }
@@ -6,93 +6,88 @@ import { useVersionCheck } from "@/hooks/use-version-check";
import { APP_VERSION } from "@/constant/env"; import { APP_VERSION } from "@/constant/env";
function getTagColor(type: string) { function getTagColor(type: string) {
if (type === "新增") return "green"; if (type === "新增") return "green";
if (type === "修复") return "red"; if (type === "修复") return "red";
if (type === "调整") return "blue"; if (type === "调整") return "blue";
if (type === "文档") return "purple"; if (type === "文档") return "purple";
return "default"; return "default";
} }
function getReleaseTitle(version: string) { function getReleaseTitle(version: string) {
return version === "Unreleased" ? "未发布" : version; return version === "Unreleased" ? "未发布" : version;
} }
type VersionReleaseModalProps = { type VersionReleaseModalProps = {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
}; };
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) { export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck(); const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
return ( return (
<> <>
<button <button
type="button"
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style}
onClick={openReleaseModal}
title="查看版本更新"
>
<span className="relative inline-flex">
{APP_VERSION}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span>
</button>
<Modal
title="版本更新"
open={open}
width={680}
centered
footer={null}
onCancel={() => setOpen(false)}
>
<div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400"></div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
</div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400"></div>
<button
type="button" type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300" className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
onClick={() => void checkLatestRelease(true)} style={style}
> onClick={openReleaseModal}
{checking ? "检查中..." : "检查更新"} title="查看版本更新"
</button> >
</div> <span className="relative inline-flex">
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div> {APP_VERSION}
</div> {hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</div> </span>
<div className="max-h-[56vh] overflow-y-auto pr-2"> </button>
<Timeline <Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
items={releases.map((release) => ({ <div className="mb-5 grid grid-cols-2 gap-3">
content: ( <div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div> <div className="text-xs text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap items-center gap-2"> <div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span> </div>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span> <div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex items-center justify-between gap-3">
{release.version === latestVersion ? <Tag color="green"></Tag> : null} <div className="text-xs text-stone-500 dark:text-stone-400"></div>
{release.version === APP_VERSION ? <Tag></Tag> : null} <button
type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => void checkLatestRelease(true)}
>
{checking ? "检查中..." : "检查更新"}
</button>
</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
</div> </div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>
))}
</div>
</div> </div>
), <div className="max-h-[56vh] overflow-y-auto pr-2">
}))} <Timeline
/> items={releases.map((release) => ({
</div> content: (
</Modal> <div>
</> <div className="flex flex-wrap items-center gap-2">
); <span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green"></Tag> : null}
{release.version === APP_VERSION ? <Tag></Tag> : null}
</div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
{item.type}
</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>
))}
</div>
</div>
),
}))}
/>
</div>
</Modal>
</>
);
} }
+85 -30
View File
@@ -1,41 +1,96 @@
"use client"; "use client";
import { Select } from "antd"; import { useMemo, useState } from "react";
import { Cpu } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { AiConfig } from "@/stores/use-config-store"; import type { AiConfig } from "@/stores/use-config-store";
type ModelPickerProps = { type ModelPickerProps = {
config: AiConfig; config: AiConfig;
value?: string; value?: string;
onChange: (model: string) => void; onChange: (model: string) => void;
className?: string; className?: string;
fullWidth?: boolean; fullWidth?: boolean;
placeholder?: string; placeholder?: string;
onMissingConfig?: () => void; onMissingConfig?: () => void;
}; };
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) { export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: model })); const [open, setOpen] = useState(false);
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`; const options = useMemo(() => Array.from(new Set([value, ...config.models].filter(Boolean))), [config.models, value]);
const current = value || "";
return ( return (
<Select <Select
showSearch open={open}
className={`canvas-control-select ${className || ""}`} value={current}
popupMatchSelectWidth={false} onOpenChange={(nextOpen) => {
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>} if (nextOpen && !options.length) {
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }} onMissingConfig?.();
value={value || undefined} return;
placeholder={placeholder} }
options={options} setOpen(nextOpen);
notFoundContent="请先到配置里拉取模型列表" }}
onChange={onChange} onValueChange={onChange}
onMouseDown={(event) => event.stopPropagation()} >
onPointerDown={(event) => event.stopPropagation()} <SelectTrigger
onClick={() => { className={cn(
if (!options.length) onMissingConfig?.(); "canvas-composer-model-picker h-8 w-fit max-w-full gap-2 rounded-full border border-input bg-transparent px-3 text-sm font-normal shadow-sm transition-colors",
}} fullWidth ? "w-full min-w-0 justify-start" : "min-w-[9rem] justify-start",
filterOption={(input, option) => String(option?.label || "").toLowerCase().includes(input.toLowerCase())} "data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/20",
/> className,
); )}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title={current || placeholder}
>
<ModelIcon model={current} />
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
</SelectTrigger>
<SelectContent
data-canvas-no-zoom
className="z-50 w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl"
position="popper"
align="start"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
{options.length ? (
options.map((model) => (
<SelectItem key={model} value={model} textValue={model}>
<ModelLabel model={model} />
</SelectItem>
))
) : (
<SelectItem value="__empty__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
);
}
function ModelLabel({ model }: { model: string }) {
return (
<span className="flex min-w-0 items-center gap-2">
<ModelIcon model={model} />
<span className="truncate">{model}</span>
</span>
);
}
function ModelIcon({ model }: { model: string }) {
const icon = resolveModelIcon(model);
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
function resolveModelIcon(model: string) {
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
return "";
} }
+52 -26
View File
@@ -6,30 +6,56 @@ 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({
return ( item,
<Card onOpen,
hoverable onCopy,
className="overflow-hidden" actionLabel = "复制",
styles={{ body: { padding: 0 } }} actionIcon = <Copy className="size-3.5" />,
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>} actionType = "text",
> extraAction,
<button type="button" className="block w-full text-left" onClick={onOpen}> }: {
<div className="p-4"> item: Prompt;
<div className="flex items-start justify-between gap-3"> onOpen: () => void;
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2> onCopy: () => void;
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span> actionLabel?: string;
</div> actionIcon?: ReactNode;
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p> actionType?: "text" | "primary";
<div className="mt-3 flex flex-wrap gap-1.5"> extraAction?: ReactNode;
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)} }) {
</div> return (
</div> <Card
</button> hoverable
<div className="flex items-center gap-2 px-4 pb-4"> className="overflow-hidden"
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel}</Button> styles={{ body: { padding: 0 } }}
{extraAction} cover={
</div> <button type="button" className="block w-full text-left" onClick={onOpen}>
</Card> <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
); </button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
{actionLabel}
</Button>
{extraAction}
</div>
</Card>
);
} }
@@ -6,29 +6,43 @@ import { Button, Modal, Space, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts"; import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) { export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return ( return (
<> <>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}> <Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
{prompt ? ( {prompt ? (
<> <>
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]"> <div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-3"> <div className="space-y-3">
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> <img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null} {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">
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p> {prompt.tags.map((tag) => (
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}</div> <Tag key={tag} className="m-0">
<Space wrap className="mt-5"> {tag}
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}></Button> </Tag>
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}></Button> : null} ))}
</Space> </div>
</div> <p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
</div> <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
</> {formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}
) : null} </div>
</Modal> <Space wrap className="mt-5">
</> <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
);
</Button>
{onSaveAsset ? (
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
</Button>
) : null}
</Space>
</div>
</div>
</>
) : null}
</Modal>
</>
);
} }
@@ -10,71 +10,79 @@ import { PromptCard } from "./prompt-card";
import { usePromptList } from "./use-prompt-list"; import { usePromptList } from "./use-prompt-list";
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) { export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
const { message } = App.useApp(); const { message } = App.useApp();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
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);
onOpenChange(false); onOpenChange(false);
}; };
useEffect(() => { useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}, [message, query.error, query.isError]); }, [message, query.error, query.isError]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => { const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget; const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage(); if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
}; };
return ( return (
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered> <Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}> <div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" /> <Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" />
</div> </div>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-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">
<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">
{promptCategories.map((category) => ( {promptCategories.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}> <Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category} {category}
</Tag.CheckableTag> </Tag.CheckableTag>
))} ))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
</Tag.CheckableTag>
);
})}
</div>
</div>
</div>
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
{query.isLoading ? (
<div className="flex h-40 items-center justify-center">
<Spin />
</div>
) : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
))}
</div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
{query.isFetchingNextPage ? (
<div className="py-4 text-center">
<Spin size="small" />
</div>
) : null}
</div>
</div> </div>
</div> </Modal>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"> );
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
</Tag.CheckableTag>
);
})}
</div>
</div>
</div>
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
{query.isLoading ? <div className="flex h-40 items-center justify-center"><Spin /></div> : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
))}
</div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
{query.isFetchingNextPage ? <div className="py-4 text-center"><Spin size="small" /></div> : null}
</div>
</div>
</Modal>
);
} }
+15 -15
View File
@@ -8,19 +8,19 @@ import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20; export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) { export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const query = useInfiniteQuery({ const query = useInfiniteQuery({
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];
return { return {
query, query,
items: useMemo(() => query.data?.pages.flatMap((page) => page.items) || [], [query.data?.pages]), items: useMemo(() => query.data?.pages.flatMap((page) => page.items) || [], [query.data?.pages]),
tags: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.tags || [])], [firstPage?.tags]), tags: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.tags || [])], [firstPage?.tags]),
categories: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.categories || [])], [firstPage?.categories]), categories: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.categories || [])], [firstPage?.categories]),
total: firstPage?.total || 0, total: firstPage?.total || 0,
}; };
} }
+164 -239
View File
@@ -1,269 +1,194 @@
"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, switch (variant) {
cx: number, case "circle":
cy: number, return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
maxRadius: number, case "square": {
viewportWidth: number, const halfW = Math.max(cx, viewportWidth - cx);
viewportHeight: number const halfH = Math.max(cy, viewportHeight - cy);
): [string, string] { const halfSide = Math.max(halfW, halfH) * 1.05;
switch (variant) { 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(", ");
case "circle": return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
case "square": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const halfSide = Math.max(halfW, halfH) * 1.05
const end = [
`${cx - halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy + halfSide}px`,
`${cx - halfSide}px ${cy + halfSide}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "triangle": {
const scale = maxRadius * 2.2
const dx = (Math.sqrt(3) / 2) * scale
const verts = [
`${cx}px ${cy - scale}px`,
`${cx + dx}px ${cy + 0.5 * scale}px`,
`${cx - dx}px ${cy + 0.5 * scale}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
}
case "diamond": {
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
const R = maxRadius * Math.SQRT2
const end = [
`${cx}px ${cy - R}px`,
`${cx + R}px ${cy}px`,
`${cx}px ${cy + R}px`,
`${cx - R}px ${cy}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "hexagon": {
const R = maxRadius * Math.SQRT2
const verts: string[] = []
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
}
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
}
case "rectangle": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const end = [
`${cx - halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy + halfH}px`,
`${cx - halfW}px ${cy + halfH}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "star": {
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
const R = maxRadius * Math.SQRT2 * 1.03
const innerRatio = 0.42
const starPolygon = (radius: number) => {
const verts: string[] = []
for (let i = 0; i < 5; i++) {
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
verts.push(
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
)
const innerA = outerA + Math.PI / 5
verts.push(
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
)
} }
return `polygon(${verts.join(", ")})` case "triangle": {
} const scale = maxRadius * 2.2;
const startR = Math.max(2, R * 0.025) const dx = (Math.sqrt(3) / 2) * scale;
return [starPolygon(startR), starPolygon(R)] const verts = [`${cx}px ${cy - scale}px`, `${cx + dx}px ${cy + 0.5 * scale}px`, `${cx - dx}px ${cy + 0.5 * scale}px`].join(", ");
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`];
}
case "diamond": {
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
const R = maxRadius * Math.SQRT2;
const end = [`${cx}px ${cy - R}px`, `${cx + R}px ${cy}px`, `${cx}px ${cy + R}px`, `${cx - R}px ${cy}px`].join(", ");
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
}
case "hexagon": {
const R = maxRadius * Math.SQRT2;
const verts: string[] = [];
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3;
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`);
}
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`];
}
case "rectangle": {
const halfW = Math.max(cx, viewportWidth - cx);
const halfH = Math.max(cy, viewportHeight - cy);
const end = [`${cx - halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy + halfH}px`, `${cx - halfW}px ${cy + halfH}px`].join(", ");
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
}
case "star": {
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
const R = maxRadius * Math.SQRT2 * 1.03;
const innerRatio = 0.42;
const starPolygon = (radius: number) => {
const verts: string[] = [];
for (let i = 0; i < 5; i++) {
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
verts.push(`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`);
const innerA = outerA + Math.PI / 5;
verts.push(`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`);
}
return `polygon(${verts.join(", ")})`;
};
const startR = Math.max(2, R * 0.025);
return [starPolygon(startR), starPolygon(R)];
}
default:
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
} }
default:
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
}
} }
export const AnimatedThemeToggler = ({ 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", // Pin the collapsed clip-path via CSS so Firefox does not paint the new
`${duration}ms` // theme unclipped between snapshot and the ready.then() JS animation.
) root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
// Pin the collapsed clip-path via CSS so Firefox does not paint the new const cleanup = () => {
// theme unclipped between snapshot and the ready.then() JS animation. delete root.dataset.magicuiThemeVt;
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) root.style.removeProperty("--magicui-theme-toggle-vt-duration");
const cleanup = () => { root.style.removeProperty("--magicui-theme-vt-clip-from");
delete root.dataset.magicuiThemeVt };
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
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(
{ {
clipPath, clipPath,
}, },
{ {
duration, duration,
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
easing: shape === "star" ? "linear" : "ease-in-out", 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" {children ?? (isDark ? <Sun /> : <Moon />)}
ref={buttonRef} <span className="sr-only">{props["aria-label"] || "切换主题"}</span>
onClick={toggleTheme} </button>
className={cn(className)} );
{...props} };
>
{children ?? (isDark ? <Sun /> : <Moon />)}
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
</button>
)
}
+190 -225
View File
@@ -1,263 +1,228 @@
"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: string | string[];
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`. /**
*/ * Colors sampled across the moving gradient band. Defaults to a built-in palette.
text: string | string[] */
/** colors?: string[];
* Colors sampled across the moving gradient band. Defaults to a built-in palette. /**
*/ * CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
colors?: string[] * @defaultValue `"var(--foreground)"`
/** */
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation. textColor?: string;
* @defaultValue `"var(--foreground)"` /**
*/ * Duration of one sweep pass, in seconds.
textColor?: string * @defaultValue `1.5`
/** */
* Duration of one sweep pass, in seconds. duration?: number;
* @defaultValue `1.5` /**
*/ * Delay before the sweep starts, in seconds.
duration?: number * @defaultValue `0`
/** */
* Delay before the sweep starts, in seconds. delay?: number;
* @defaultValue `0` /**
*/ * When `text` is an array, replay the sweep and advance to the next string after each completion.
delay?: number * @defaultValue `false`
/** */
* When `text` is an array, replay the sweep and advance to the next string after each completion. repeat?: boolean;
* @defaultValue `false` /**
*/ * Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
repeat?: boolean * @defaultValue `0.5`
/** */
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds. repeatDelay?: number;
* @defaultValue `0.5` /**
*/ * If `true`, the animation starts only after the element enters the viewport.
repeatDelay?: number * @defaultValue `true`
/** */
* If `true`, the animation starts only after the element enters the viewport. startOnView?: boolean;
* @defaultValue `true` /**
*/ * Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
startOnView?: boolean * @defaultValue `true`
/** */
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back). once?: boolean;
* @defaultValue `true` /**
*/ * Additional class names for the animated `span` (e.g. typography utilities).
once?: boolean */
/** className?: string;
* Additional class names for the animated `span` (e.g. typography utilities). /**
*/ * When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
className?: string * @defaultValue `false`
/** */
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line. fixedWidth?: boolean;
* @defaultValue `false`
*/
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,
duration, duration,
delay, delay,
repeat, repeat,
repeatDelay, repeatDelay,
texts, texts,
}) });
optsRef.current = { optsRef.current = {
colors, colors,
textColor, textColor,
duration, duration,
delay, delay,
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
ref={spanRef} ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)} className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{ style={{
transform: "translateY(-2px)", transform: "translateY(-2px)",
color: "transparent", color: "transparent",
backgroundClip: "text", backgroundClip: "text",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
backgroundImage, backgroundImage,
...(isMulti && { ...(isMulti && {
display: "inline-block", display: "inline-block",
overflow: "hidden", overflow: "hidden",
whiteSpace: "nowrap", whiteSpace: "nowrap",
verticalAlign: "text-center", verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }), ...(fixedW != null && { width: fixedW }),
}), }),
}} }}
animate={animatedW != null ? { width: animatedW } : undefined} animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }} transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props} {...props}
> >
{texts[activeIndex]} {texts[activeIndex]}
</motion.span> </motion.span>
) );
} }
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
hideChevron = false,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
hideChevron?: boolean
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
{!hideChevron ? (
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="canvas-select-chevron pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
) : null}
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+20 -20
View File
@@ -1,26 +1,26 @@
import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react"; import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react";
export const navigationTools = [ export const navigationTools = [
{ {
slug: "canvas", slug: "canvas",
label: "我的画布", label: "我的画布",
icon: Maximize2, icon: Maximize2,
}, },
{ {
slug: "image", slug: "image",
label: "生图工作台", label: "生图工作台",
icon: ImagePlus, icon: ImagePlus,
}, },
{ {
slug: "prompts", slug: "prompts",
label: "提示词库", label: "提示词库",
icon: FileText, icon: FileText,
}, },
{ {
slug: "assets", slug: "assets",
label: "我的素材", label: "我的素材",
icon: Images, icon: Images,
}, },
] as const; ] as const;
export type NavigationToolSlug = (typeof navigationTools)[number]["slug"]; export type NavigationToolSlug = (typeof navigationTools)[number]["slug"];
+5 -5
View File
@@ -4,10 +4,10 @@ import { App } from "antd";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
export function useCopyText() { export function useCopyText() {
const { message } = App.useApp(); const { message } = App.useApp();
return (value: string, successText = "已复制") => { return (value: string, successText = "已复制") => {
copy(value); copy(value);
message.success(successText); message.success(successText);
}; };
} }
+70 -70
View File
@@ -7,88 +7,88 @@ const latestVersionUrl = "https://raw.githubusercontent.com/basketikun/infinite-
const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md"; const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md";
function readLocalReleases(): ReleaseInfo[] { function readLocalReleases(): ReleaseInfo[] {
try { try {
return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]"); return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
} catch { } catch {
return []; return [];
} }
} }
function toVersionParts(version: string) { function toVersionParts(version: string) {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
return match ? match.slice(1).map(Number) : null; return match ? match.slice(1).map(Number) : null;
} }
function isNewerVersion(latestVersion: string, currentVersion: string) { function isNewerVersion(latestVersion: string, currentVersion: string) {
const latest = toVersionParts(latestVersion); const latest = toVersionParts(latestVersion);
const current = toVersionParts(currentVersion); const current = toVersionParts(currentVersion);
if (!latest || !current) return false; if (!latest || !current) return false;
return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex])); return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]));
} }
export function useVersionCheck() { export function useVersionCheck() {
const currentVersion = APP_VERSION; const currentVersion = APP_VERSION;
const { message } = App.useApp(); const { message } = App.useApp();
const localReleases = useMemo(readLocalReleases, []); const localReleases = useMemo(readLocalReleases, []);
const [latestVersion, setLatestVersion] = useState(currentVersion); const [latestVersion, setLatestVersion] = useState(currentVersion);
const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases); const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const hasNewVersion = isNewerVersion(latestVersion, currentVersion); const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
const checkLatestVersion = useCallback(async () => { const checkLatestVersion = useCallback(async () => {
try { try {
const response = await fetch(latestVersionUrl); const response = await fetch(latestVersionUrl);
if (!response.ok) return false; if (!response.ok) return false;
const version = await response.text(); const version = await response.text();
setLatestVersion(version.trim() || currentVersion); setLatestVersion(version.trim() || currentVersion);
return true; return true;
} catch { } catch {
return false; return false;
} }
}, [currentVersion]); }, [currentVersion]);
const checkLatestRelease = useCallback(async (showMessage = false) => { const checkLatestRelease = useCallback(
setChecking(true); async (showMessage = false) => {
try { setChecking(true);
const [versionResponse, changelogResponse] = await Promise.all([ try {
fetch(latestVersionUrl), const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]);
fetch(latestChangelogUrl), if (!versionResponse.ok) throw new Error("版本读取失败");
]); if (!changelogResponse.ok) throw new Error("更新日志读取失败");
if (!versionResponse.ok) throw new Error("版本读取失败"); const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
if (!changelogResponse.ok) throw new Error("更新日志读取失败"); setLatestVersion(version.trim() || currentVersion);
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]); if (changelog.trim()) setReleases(parseChangelog(changelog));
setLatestVersion(version.trim() || currentVersion); if (showMessage) message.success("已获取最新版本信息");
if (changelog.trim()) setReleases(parseChangelog(changelog)); return true;
if (showMessage) message.success("已获取最新版本信息"); } catch {
return true; setLatestVersion(currentVersion);
} catch { setReleases(localReleases);
setLatestVersion(currentVersion); if (showMessage) message.error("获取最新版本信息失败");
setReleases(localReleases); return false;
if (showMessage) message.error("获取最新版本信息失败"); } finally {
return false; setChecking(false);
} finally { }
setChecking(false); },
} [currentVersion, localReleases, message],
}, [currentVersion, localReleases, message]); );
useEffect(() => { useEffect(() => {
void checkLatestVersion(); void checkLatestVersion();
}, [checkLatestVersion]); }, [checkLatestVersion]);
const openReleaseModal = useCallback(() => { const openReleaseModal = useCallback(() => {
setOpen(true); setOpen(true);
void checkLatestRelease(); void checkLatestRelease();
}, [checkLatestRelease]); }, [checkLatestRelease]);
return { return {
open, open,
setOpen, setOpen,
openReleaseModal, openReleaseModal,
latestVersion, latestVersion,
releases, releases,
checking, checking,
hasNewVersion, hasNewVersion,
checkLatestRelease, checkLatestRelease,
}; };
} }
+65 -65
View File
@@ -3,76 +3,76 @@ import type { ThemeConfig } from "antd";
import { theme as antdTheme } from "antd"; import { theme as antdTheme } from "antd";
const neutral = { const neutral = {
light: { light: {
primary: "#171717", primary: "#171717",
primaryHover: "#000000", primaryHover: "#000000",
primaryText: "#ffffff", primaryText: "#ffffff",
menuBg: "#f5f5f5", menuBg: "#f5f5f5",
menuText: "#171717", menuText: "#171717",
selectActiveBg: "#f5f5f5", selectActiveBg: "#f5f5f5",
selectSelectedBg: "#f0f0f0", selectSelectedBg: "#f0f0f0",
selectText: "#171717", selectText: "#171717",
tableSelectedBg: "rgba(17, 17, 17, 0.05)", tableSelectedBg: "rgba(17, 17, 17, 0.05)",
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)", tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
}, },
dark: { dark: {
primary: "#fafafa", primary: "#fafafa",
primaryHover: "#ffffff", primaryHover: "#ffffff",
primaryText: "#171717", primaryText: "#171717",
menuBg: "#262626", menuBg: "#262626",
menuText: "#fafafa", menuText: "#fafafa",
selectActiveBg: "#262626", selectActiveBg: "#262626",
selectSelectedBg: "#333333", selectSelectedBg: "#333333",
selectText: "#fafafa", selectText: "#fafafa",
tableSelectedBg: "rgba(255, 255, 255, 0.08)", tableSelectedBg: "rgba(255, 255, 255, 0.08)",
tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)", tableSelectedHoverBg: "rgba(255, 255, 255, 0.12)",
}, },
}; };
export const adminLayoutStyle = { export const adminLayoutStyle = {
siderWidth: 232, siderWidth: 232,
headerHeight: 56, headerHeight: 56,
brandHeight: 64, brandHeight: 64,
menu: { borderInlineEnd: 0, padding: "18px 12px", fontSize: 15 } satisfies CSSProperties, menu: { borderInlineEnd: 0, padding: "18px 12px", fontSize: 15 } satisfies CSSProperties,
menuItem: { height: 44, lineHeight: "44px", marginBlock: 4, borderRadius: 8 } satisfies CSSProperties, menuItem: { height: 44, lineHeight: "44px", marginBlock: 4, borderRadius: 8 } satisfies CSSProperties,
}; };
export function getAntThemeConfig(dark: boolean): ThemeConfig { export function getAntThemeConfig(dark: boolean): ThemeConfig {
const color = dark ? neutral.dark : neutral.light; const color = dark ? neutral.dark : neutral.light;
return { return {
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" }, cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" },
token: { token: {
colorPrimary: color.primary, colorPrimary: color.primary,
colorInfo: color.primary, colorInfo: color.primary,
colorLink: color.primary, colorLink: color.primary,
colorLinkHover: color.primaryHover, colorLinkHover: color.primaryHover,
colorLinkActive: color.primary, colorLinkActive: color.primary,
colorTextLightSolid: color.primaryText, colorTextLightSolid: color.primaryText,
}, },
components: { components: {
Button: { Button: {
primaryShadow: "none", primaryShadow: "none",
}, },
Menu: { Menu: {
itemActiveBg: color.menuBg, itemActiveBg: color.menuBg,
itemHoverBg: color.menuBg, itemHoverBg: color.menuBg,
itemSelectedBg: color.menuBg, itemSelectedBg: color.menuBg,
itemSelectedColor: color.menuText, itemSelectedColor: color.menuText,
darkItemHoverBg: neutral.dark.menuBg, darkItemHoverBg: neutral.dark.menuBg,
darkItemSelectedBg: neutral.dark.menuBg, darkItemSelectedBg: neutral.dark.menuBg,
darkItemSelectedColor: neutral.dark.menuText, darkItemSelectedColor: neutral.dark.menuText,
}, },
Select: { Select: {
optionActiveBg: color.selectActiveBg, optionActiveBg: color.selectActiveBg,
optionSelectedBg: color.selectSelectedBg, optionSelectedBg: color.selectSelectedBg,
optionSelectedColor: color.selectText, optionSelectedColor: color.selectText,
}, },
Table: { Table: {
rowSelectedBg: color.tableSelectedBg, rowSelectedBg: color.tableSelectedBg,
rowSelectedHoverBg: color.tableSelectedHoverBg, rowSelectedHoverBg: color.tableSelectedHoverBg,
}, },
}, },
}; };
} }
+54 -54
View File
@@ -2,62 +2,62 @@ export type CanvasColorTheme = "light" | "dark";
export type CanvasBackgroundMode = "dots" | "lines" | "blank"; export type CanvasBackgroundMode = "dots" | "lines" | "blank";
export const canvasThemes = { export const canvasThemes = {
light: { light: {
canvas: { canvas: {
background: "#f4f2ed", background: "#f4f2ed",
dot: "rgba(68,64,60,.28)", dot: "rgba(68,64,60,.28)",
line: "rgba(68,64,60,.12)", line: "rgba(68,64,60,.12)",
selectionStroke: "#1c1917", selectionStroke: "#1c1917",
selectionFill: "rgba(28,25,23,.06)", selectionFill: "rgba(28,25,23,.06)",
},
node: {
label: "#57534e",
fill: "#e7e5df",
panel: "#fbfaf7",
stroke: "#d6d3ca",
activeStroke: "#1c1917",
placeholder: "#8a8479",
text: "#292524",
muted: "#78716c",
faint: "#a8a29e",
},
toolbar: {
panel: "rgba(251,250,247,.96)",
border: "#d6d3ca",
item: "#57534e",
itemHover: "#e7e5df",
activeBg: "#e7e5df",
activeText: "#292524",
},
}, },
node: { dark: {
label: "#57534e", canvas: {
fill: "#e7e5df", background: "#181715",
panel: "#fbfaf7", dot: "rgba(245,245,244,.24)",
stroke: "#d6d3ca", line: "rgba(245,245,244,.10)",
activeStroke: "#1c1917", selectionStroke: "#fafaf9",
placeholder: "#8a8479", selectionFill: "rgba(250,250,249,.10)",
text: "#292524", },
muted: "#78716c", node: {
faint: "#a8a29e", label: "#d6d3d1",
fill: "#292524",
panel: "#1f1d1a",
stroke: "#44403c",
activeStroke: "#fafaf9",
placeholder: "#a8a29e",
text: "#f5f5f4",
muted: "#d6d3d1",
faint: "#78716c",
},
toolbar: {
panel: "rgba(31,29,26,.96)",
border: "#44403c",
item: "#d6d3d1",
itemHover: "#292524",
activeBg: "#3a3631",
activeText: "#f5f5f4",
},
}, },
toolbar: {
panel: "rgba(251,250,247,.96)",
border: "#d6d3ca",
item: "#57534e",
itemHover: "#e7e5df",
activeBg: "#e7e5df",
activeText: "#292524",
},
},
dark: {
canvas: {
background: "#181715",
dot: "rgba(245,245,244,.24)",
line: "rgba(245,245,244,.10)",
selectionStroke: "#fafaf9",
selectionFill: "rgba(250,250,249,.10)",
},
node: {
label: "#d6d3d1",
fill: "#292524",
panel: "#1f1d1a",
stroke: "#44403c",
activeStroke: "#fafaf9",
placeholder: "#a8a29e",
text: "#f5f5f4",
muted: "#d6d3d1",
faint: "#78716c",
},
toolbar: {
panel: "rgba(31,29,26,.96)",
border: "#44403c",
item: "#d6d3d1",
itemHover: "#292524",
activeBg: "#3a3631",
activeText: "#f5f5f4",
},
},
} as const; } as const;
export type CanvasTheme = (typeof canvasThemes)[CanvasColorTheme]; export type CanvasTheme = (typeof canvasThemes)[CanvasColorTheme];
+43 -43
View File
@@ -1,62 +1,62 @@
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
export function formatBytes(bytes: number) { export function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) { if (!Number.isFinite(bytes) || bytes <= 0) {
return ""; return "";
} }
const units = ["B", "KB", "MB", "GB"]; const units = ["B", "KB", "MB", "GB"];
let value = bytes; let value = bytes;
let unitIndex = 0; let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) { while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024; value /= 1024;
unitIndex += 1; unitIndex += 1;
} }
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
} }
export function formatDuration(ms: number) { export function formatDuration(ms: number) {
const value = Math.max(0, Math.floor(ms / 1000)); const value = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(value / 60); const minutes = Math.floor(value / 60);
const seconds = value % 60; const seconds = value % 60;
return minutes ? `${minutes}${String(seconds).padStart(2, "0")}` : `${seconds}`; return minutes ? `${minutes}${String(seconds).padStart(2, "0")}` : `${seconds}`;
} }
export function getDataUrlByteSize(dataUrl: string) { export function getDataUrlByteSize(dataUrl: string) {
const base64 = dataUrl.split(",", 2)[1]; const base64 = dataUrl.split(",", 2)[1];
if (!base64) { if (!base64) {
return 0; return 0;
} }
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
} }
export function readFileAsDataUrl(file: File) { export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "")); reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败")); reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
export function readImageMeta(dataUrl: string) { export function readImageMeta(dataUrl: string) {
return new Promise<{ width: number; height: number; mimeType: string }>((resolve) => { return new Promise<{ width: number; height: number; mimeType: string }>((resolve) => {
const image = new Image(); const image = new Image();
const done = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" }); const done = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
image.onload = done; image.onload = done;
image.onerror = done; image.onerror = done;
setTimeout(done, 3000); setTimeout(done, 3000);
image.src = dataUrl; image.src = dataUrl;
}); });
} }
export function dataUrlToFile(image: ReferenceImage) { export function dataUrlToFile(image: ReferenceImage) {
const [header, content] = image.dataUrl.split(",", 2); const [header, content] = image.dataUrl.split(",", 2);
const mimeType = header.match(/data:(.*?);base64/)?.[1] || image.type || "image/png"; const mimeType = header.match(/data:(.*?);base64/)?.[1] || image.type || "image/png";
const binary = atob(content || ""); const binary = atob(content || "");
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) { for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index); bytes[index] = binary.charCodeAt(index);
} }
return new File([bytes], image.name || "reference.png", { type: mimeType }); return new File([bytes], image.name || "reference.png", { type: mimeType });
} }
+26 -26
View File
@@ -2,33 +2,33 @@ import localforage from "localforage";
import type { StateStorage } from "zustand/middleware"; import type { StateStorage } from "zustand/middleware";
localforage.config({ localforage.config({
name: "infinite-canvas", name: "infinite-canvas",
storeName: "app_state", storeName: "app_state",
}); });
export const localForageStorage: StateStorage = { export const localForageStorage: StateStorage = {
getItem: async (name) => { getItem: async (name) => {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { try {
return (await localforage.getItem<string>(name)) || null; return (await localforage.getItem<string>(name)) || null;
} catch { } catch {
return window.localStorage.getItem(name); return window.localStorage.getItem(name);
} }
}, },
setItem: async (name, value) => { setItem: async (name, value) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
await localforage.setItem(name, value); await localforage.setItem(name, value);
} catch { } catch {
window.localStorage.setItem(name, value); window.localStorage.setItem(name, value);
} }
}, },
removeItem: async (name) => { removeItem: async (name) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
await localforage.removeItem(name); await localforage.removeItem(name);
} catch { } catch {
window.localStorage.removeItem(name); window.localStorage.removeItem(name);
} }
}, },
}; };
+19 -19
View File
@@ -1,24 +1,24 @@
export type ReleaseInfo = { export type ReleaseInfo = {
version: string; version: string;
date: string; date: string;
items: { type: string; content: string }[]; items: { type: string; content: string }[];
}; };
export function parseChangelog(content: string): ReleaseInfo[] { export function parseChangelog(content: string): ReleaseInfo[] {
return content return content
.split(/^## /m) .split(/^## /m)
.slice(1) .slice(1)
.map((block) => { .map((block) => {
const [title = "", ...lines] = block.trim().split("\n"); const [title = "", ...lines] = block.trim().split("\n");
const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || []; const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
return { return {
version: version.trim(), version: version.trim(),
date: date.trim(), date: date.trim(),
items: lines items: lines
.map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/)) .map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match)) .filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => ({ type: match[1], content: match[2] })), .map((match) => ({ type: match[1], content: match[2] })),
}; };
}) })
.filter((release) => release.items.length); .filter((release) => release.items.length);
} }
+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));
} }

Some files were not shown because too many files have changed in this diff Show More