mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83031e0864 | |||
| 593feddc3f | |||
| c9682481d7 | |||
| 2e4c0d0f0a | |||
| 697e1fc1eb | |||
| ca4e87bddf | |||
| e319481976 | |||
| cee08cb5a8 | |||
| 133861590e | |||
| 6049ad2df3 | |||
| 03396a9d18 |
@@ -2,6 +2,15 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.0.7 - 2026-05-23
|
||||
|
||||
+ [新增] 管理后台提示词管理支持多选批量删除。
|
||||
+ [新增] 新增定义拉取GitHub提示词源功能。
|
||||
+ [新增] 新增awesome-gpt-image2-prompts提示词来源。
|
||||
+ [优化] 优化模型下拉选择样式、优化生图编辑设置
|
||||
|
||||
## v0.0.6 - 2026-05-22
|
||||
|
||||
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
|
||||
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
|
||||
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------|----------|----------|
|
||||
| `channels` | object[] | 模型渠道配置列表 |
|
||||
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||
|
||||
`channels` 每项字段:
|
||||
|
||||
@@ -143,6 +144,13 @@
|
||||
| `enabled` | bool | 是否启用 |
|
||||
| `remark` | string | 备注 |
|
||||
|
||||
`promptSync` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 是否开启定时同步,默认开启 |
|
||||
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||
|
||||
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||
|
||||
### dicts
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# 待测试
|
||||
|
||||
- 模型选择器改为共享的 shadcn 风格下拉,按模型名称显示 OpenAI、Claude、Gemini 图标,并把画布节点下方、右侧助手和配置弹窗统一到同一套组件;右侧助手的模型和模式按钮会随工具栏宽度自动在文字态和图标态之间切换,需要确认生图页、画布助手、节点配置和配置弹窗中的选中态和下拉列表显示正常。
|
||||
- 画布图像设置里的尺寸输入去掉了原生数字步进箭头,需要确认宽高仍可直接输入、禁用态正常、设置值回写正常。
|
||||
- 后台提示词定时同步默认改为开启且默认每 5 分钟执行一次,需要确认新环境未保存过设置时开关默认打开、Cron 默认值正确,用户手动关闭或修改 Cron 后能保持配置。
|
||||
- 画布右侧助手和节点下方对话框的生图设置弹层改为紧凑图像设置面板,面板只包含质量、尺寸、宽高比和生成数量选择,模型选择保留在对话框底部工具栏;需要确认助手内设置面板不会被右侧面板裁切,节点内质量可正常选择并参与生成、尺寸可编辑、宽高比预览显示正常且为直角矩形、auto 不重复显示、张数每行 4 个且自定义输入框能显示当前值、实际生图参数正常;节点设置面板打开时会隐藏节点悬浮工具条,避免遮挡质量选择。
|
||||
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
|
||||
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
|
||||
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
|
||||
- 提示词远程源新增 `davidwuw0811-boop/awesome-gpt-image2-prompts`,同步时会读取仓库 `prompts.json` 的结构化数据,需要确认后台同步后分类、封面图、标签和提示词内容展示正常。
|
||||
|
||||
+13
-1
@@ -59,13 +59,18 @@
|
||||
"enabled": true,
|
||||
"remark": ""
|
||||
}
|
||||
]
|
||||
],
|
||||
"promptSync": {
|
||||
"enabled": true,
|
||||
"cron": "*/5 * * * *"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `channels` | object[] | 模型渠道列表 |
|
||||
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||
|
||||
`channels` 每项字段:
|
||||
|
||||
@@ -81,3 +86,10 @@
|
||||
| `remark` | string | 备注 |
|
||||
|
||||
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
||||
|
||||
`promptSync` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | boolean | 是否开启定时同步,默认开启 |
|
||||
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 |
|
||||
| https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts | 已实现同步逻辑 |
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
|
||||
@@ -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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
||||
@@ -13,6 +13,10 @@ type adminSyncRequest struct {
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type adminBatchDeleteRequest struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, service.ListPromptCategories())
|
||||
}
|
||||
@@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
|
||||
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) {
|
||||
var request adminSyncRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
|
||||
@@ -15,5 +15,6 @@ func main() {
|
||||
if err := service.EnsureDefaultAdmin(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
service.StartPromptSyncScheduler()
|
||||
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ type PublicSetting struct {
|
||||
// PrivateSetting 私有配置。
|
||||
type PrivateSetting struct {
|
||||
Channels []ModelChannel `json:"channels"`
|
||||
PromptSync PromptSyncSetting `json:"promptSync"`
|
||||
}
|
||||
|
||||
// PromptSyncSetting 提示词定时同步配置。
|
||||
type PromptSyncSetting struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
}
|
||||
|
||||
// Setting 系统配置。
|
||||
|
||||
@@ -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: "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: "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 (
|
||||
|
||||
@@ -99,6 +99,15 @@ func DeletePrompt(id string) 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 用远程同步结果替换整个提示词分类。
|
||||
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
||||
db, err := DB()
|
||||
|
||||
@@ -41,6 +41,7 @@ func New() *gin.Engine {
|
||||
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
||||
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
||||
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
||||
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
|
||||
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
||||
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/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"
|
||||
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"}
|
||||
@@ -34,6 +35,20 @@ type gptImage2Data struct {
|
||||
} `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) {
|
||||
for _, item := range repository.PromptCategories() {
|
||||
if item.Category != category {
|
||||
@@ -63,6 +78,8 @@ func buildPromptCategory(category string) ([]model.Prompt, error) {
|
||||
return buildYouMindGptImage2Prompts()
|
||||
case "youmind-nano-banana-pro":
|
||||
return buildYouMindNanoBananaProPrompts()
|
||||
case "davidwu-gpt-image2-prompts":
|
||||
return buildDavidWuGptImage2Prompts()
|
||||
}
|
||||
return nil, errors.New("未知提示词分类")
|
||||
}
|
||||
@@ -173,6 +190,31 @@ func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
|
||||
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) {
|
||||
markdown, err := fetchText(baseURL, "README_zh.md")
|
||||
if err != nil {
|
||||
@@ -234,6 +276,28 @@ func youMindTags(title, modelTag string) []string {
|
||||
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, "")
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
func splitTags(value string, pattern string) []string {
|
||||
tags := []string{}
|
||||
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
|
||||
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 {
|
||||
codes := []string{}
|
||||
for _, item := range items {
|
||||
|
||||
@@ -32,6 +32,9 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
|
||||
settings = normalizeSettings(settings)
|
||||
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
||||
result, err := repository.SaveSettings(settings, now())
|
||||
if err == nil {
|
||||
RefreshPromptSyncScheduler()
|
||||
}
|
||||
return hidePrivateAPIKeys(result), err
|
||||
}
|
||||
|
||||
@@ -72,6 +75,7 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
||||
if setting.Channels == nil {
|
||||
setting.Channels = []model.ModelChannel{}
|
||||
}
|
||||
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
|
||||
for i := range setting.Channels {
|
||||
if setting.Channels[i].Protocol == "" {
|
||||
setting.Channels[i].Protocol = "openai"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 255
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
bun.lock
|
||||
*.log
|
||||
*.ico
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.webp
|
||||
*.svg
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 255,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
@@ -1164,6 +1165,8 @@
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
+4
-1
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
@@ -39,6 +41,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -41,7 +41,10 @@ export default function AdminAssetsPage() {
|
||||
...value,
|
||||
type: nextType,
|
||||
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
|
||||
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
|
||||
tags: (value.tagText || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
setEditingAsset(null);
|
||||
};
|
||||
@@ -57,7 +60,11 @@ export default function AdminAssetsPage() {
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
|
||||
render: (_, item) => (
|
||||
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
@@ -69,7 +76,13 @@ export default function AdminAssetsPage() {
|
||||
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>,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
@@ -84,9 +97,15 @@ export default function AdminAssetsPage() {
|
||||
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>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@@ -98,10 +117,31 @@ export default function AdminAssetsPage() {
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
|
||||
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>查询</Button></Space></Form.Item></Col>
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="180px">
|
||||
<Form.Item label="类型">
|
||||
<Select value={kind} onChange={changeKind} options={typeOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="标签">
|
||||
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
@@ -114,22 +154,59 @@ export default function AdminAssetsPage() {
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={<Space><Typography.Text strong>素材列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>素材列表</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
||||
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) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
||||
<Form.Item name="category" label="分类"><Input /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
|
||||
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
|
||||
<Select options={editTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
{formType === "image" ? (
|
||||
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -139,18 +216,44 @@ export default function AdminAssetsPage() {
|
||||
<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>
|
||||
<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}
|
||||
{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>
|
||||
<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="删除素材"
|
||||
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>
|
||||
|
||||
@@ -52,22 +52,40 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||
<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>
|
||||
<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 }))}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -10,12 +10,37 @@ import type { Prompt } from "@/services/api/prompts";
|
||||
import { useAdminPrompts } from "./use-admin-prompts";
|
||||
|
||||
export default function AdminPromptsPage() {
|
||||
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts();
|
||||
const {
|
||||
categories,
|
||||
prompts,
|
||||
tags,
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
isLoading,
|
||||
isSyncing,
|
||||
searchPrompts,
|
||||
changeCategory,
|
||||
changeTag,
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetFilters,
|
||||
refreshPrompts,
|
||||
syncCategory,
|
||||
savePrompt: saveAdminPrompt,
|
||||
deletePrompt,
|
||||
deletePrompts,
|
||||
} = useAdminPrompts();
|
||||
const copyText = useCopyText();
|
||||
const [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;
|
||||
@@ -28,10 +53,24 @@ export default function AdminPromptsPage() {
|
||||
|
||||
const savePrompt = async () => {
|
||||
const value = await form.validateFields();
|
||||
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
|
||||
await saveAdminPrompt({
|
||||
...editingPrompt,
|
||||
...value,
|
||||
category: value.category || defaultCategory,
|
||||
tags: (value.tagText || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
setEditingPrompt(null);
|
||||
};
|
||||
|
||||
const batchDeletePrompts = async () => {
|
||||
await deletePrompts(selectedPromptIds);
|
||||
setSelectedPromptIds([]);
|
||||
setIsBatchDeleteOpen(false);
|
||||
};
|
||||
|
||||
const columns: ProColumns<Prompt>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
@@ -43,7 +82,11 @@ export default function AdminPromptsPage() {
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
|
||||
render: (_, item) => (
|
||||
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
@@ -55,7 +98,13 @@ export default function AdminPromptsPage() {
|
||||
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>,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
@@ -64,9 +113,15 @@ export default function AdminPromptsPage() {
|
||||
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>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@@ -78,10 +133,31 @@ export default function AdminPromptsPage() {
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>查询</Button></Space></Form.Item></Col>
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="分组">
|
||||
<Select value={category} onChange={changeCategory} options={categoryOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="标签">
|
||||
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
@@ -94,20 +170,54 @@ export default function AdminPromptsPage() {
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={<Space><Typography.Text strong>提示词列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>提示词列表</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
||||
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>同步</Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>新增</Button>]}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
||||
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
|
||||
批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
|
||||
</Button>,
|
||||
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
|
||||
同步
|
||||
</Button>,
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
||||
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -117,35 +227,111 @@ export default function AdminPromptsPage() {
|
||||
<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>
|
||||
<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}
|
||||
{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}
|
||||
<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="同步远程提示词源"
|
||||
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> },
|
||||
{
|
||||
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="取消">
|
||||
<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,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||
import type { Prompt } from "@/services/api/prompts";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
@@ -71,6 +71,18 @@ export function useAdminPrompts() {
|
||||
},
|
||||
});
|
||||
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("提示词已批量删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "批量删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const error = categoriesQuery.error || promptsQuery.error;
|
||||
if (!error) return;
|
||||
@@ -101,7 +113,7 @@ export function useAdminPrompts() {
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||
isSyncing: syncMutation.isPending,
|
||||
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
||||
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
||||
@@ -116,5 +128,6 @@ export function useAdminPrompts() {
|
||||
},
|
||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const emptySettings: AdminSettings = {
|
||||
allowCustomChannel: true,
|
||||
},
|
||||
},
|
||||
private: { channels: [] },
|
||||
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } },
|
||||
};
|
||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function AdminSettingsPage() {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
||||
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
||||
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
|
||||
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
|
||||
const activeMode = editorMode[activeTab];
|
||||
const activeJsonText = jsonText[activeTab];
|
||||
@@ -267,8 +268,12 @@ export default function AdminSettingsPage() {
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>刷新</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>保存设置</Button>
|
||||
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
@@ -278,12 +283,23 @@ export default function AdminSettingsPage() {
|
||||
<Segmented
|
||||
value={activeMode}
|
||||
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
|
||||
options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]}
|
||||
options={[
|
||||
{ label: "可视化编辑", value: "visual" },
|
||||
{ label: "手动编辑 JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
{activeMode === "json" ? (
|
||||
<Space>
|
||||
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON 格式正确</Tag>}
|
||||
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>格式化</Button>
|
||||
{jsonError ? (
|
||||
<Tag color="error">{jsonError}</Tag>
|
||||
) : (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
JSON 格式正确
|
||||
</Tag>
|
||||
)}
|
||||
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>
|
||||
格式化
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
|
||||
@@ -294,12 +310,36 @@ export default function AdminSettingsPage() {
|
||||
activeMode === "visual" ? (
|
||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}><Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)"><Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||
<Col span={24}><Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
|
||||
<Col span={24}><Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)">
|
||||
<Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
) : (
|
||||
@@ -318,36 +358,65 @@ export default function AdminSettingsPage() {
|
||||
) : activeMode === "visual" ? (
|
||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||
<Flex vertical gap={12}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>新增渠道</Button>
|
||||
<Alert showIcon type="warning" title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。" />
|
||||
<Card size="small" title="提示词定时同步">
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
|
||||
<Input placeholder="*/5 * * * *" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>
|
||||
新增渠道
|
||||
</Button>
|
||||
<Table
|
||||
rowKey={(_, index) => String(index)}
|
||||
rowKey="_rowKey"
|
||||
pagination={false}
|
||||
dataSource={channels}
|
||||
dataSource={channelTableData}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
||||
{ title: "模型", dataIndex: "models", render: (value: string[]) => <Typography.Text ellipsis style={{ maxWidth: 360 }}>{modelSummary(value || [])}</Typography.Text> },
|
||||
{
|
||||
title: "模型",
|
||||
dataIndex: "models",
|
||||
render: (value: string[]) => (
|
||||
<Typography.Text ellipsis style={{ maxWidth: 360 }}>
|
||||
{modelSummary(value || [])}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: "权重", dataIndex: "weight", width: 88 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 220,
|
||||
align: "right",
|
||||
render: (_, __, index) => (
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" onClick={() => openTestDialog(index)}>测试</Button>
|
||||
<Button size="small" onClick={() => openChannelDrawer(index)}>编辑</Button>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
||||
<Button size="small" onClick={() => openTestDialog(item._index)}>
|
||||
测试
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openChannelDrawer(item._index)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
const nextChannels = [...channels];
|
||||
nextChannels.splice(index, 1);
|
||||
nextChannels.splice(item._index, 1);
|
||||
void persistChannels(nextChannels);
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -369,33 +438,90 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Drawer title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"} open={isChannelDrawerOpen} size={560} onClose={closeChannelDrawer} extra={<Space><Button onClick={closeChannelDrawer}>取消</Button><Button type="primary" onClick={() => void saveChannel()}>保存</Button></Space>} destroyOnHidden>
|
||||
<Drawer
|
||||
title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"}
|
||||
open={isChannelDrawerOpen}
|
||||
size={560}
|
||||
onClose={closeChannelDrawer}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeChannelDrawer}>取消</Button>
|
||||
<Button type="primary" onClick={() => void saveChannel()}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}><Input /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="protocol" label="协议"><Select options={[{ label: "OpenAI", value: "openai" }]} /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="weight" label="权重"><InputNumber min={1} step={1} className="!w-full" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||
<Col span={24}><Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}><Input /></Form.Item></Col>
|
||||
<Col span={24}><Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}><Input.Password /></Form.Item></Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="protocol" label="协议">
|
||||
<Select options={[{ label: "OpenAI", value: "openai" }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="weight" label="权重">
|
||||
<InputNumber min={1} step={1} className="!w-full" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="渠道可用模型">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="models" noStyle><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>获取模型列表</Button>
|
||||
<Form.Item name="models" noStyle>
|
||||
<Select mode="tags" tokenSeparators={[",", "\n"]} />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
|
||||
获取模型列表
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}><Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item></Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal
|
||||
title={<Space>{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text></Space>}
|
||||
title={
|
||||
<Space>
|
||||
{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
open={testChannelIndex !== null}
|
||||
width={920}
|
||||
onCancel={closeTestDialog}
|
||||
footer={<Space><Button onClick={closeTestDialog}>取消</Button><Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>批量测试 {selectedTestModels.length} 个模型</Button></Space>}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={closeTestDialog}>取消</Button>
|
||||
<Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>
|
||||
批量测试 {selectedTestModels.length} 个模型
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
@@ -435,7 +561,11 @@ export default function AdminSettingsPage() {
|
||||
key: "actions",
|
||||
width: 120,
|
||||
align: "right",
|
||||
render: (_, item) => <Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>测试</Button>,
|
||||
render: (_, item) => (
|
||||
<Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>
|
||||
测试
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -470,6 +600,10 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
|
||||
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
||||
return {
|
||||
channels: (setting.channels || []).map(normalizeChannel),
|
||||
promptSync: {
|
||||
enabled: setting.promptSync?.enabled !== false,
|
||||
cron: setting.promptSync?.cron || "*/5 * * * *",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -493,7 +627,7 @@ function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminS
|
||||
}));
|
||||
return {
|
||||
public: saved.public,
|
||||
private: { channels },
|
||||
private: { ...saved.private, channels },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function AssetLibraryPage() {
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
||||
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||
};
|
||||
|
||||
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
||||
@@ -95,7 +95,17 @@ export default function AssetLibraryPage() {
|
||||
<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); }} />
|
||||
<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">
|
||||
@@ -106,7 +116,15 @@ export default function AssetLibraryPage() {
|
||||
{ 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); }}>
|
||||
<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>
|
||||
))}
|
||||
@@ -115,11 +133,26 @@ export default function AssetLibraryPage() {
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="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
|
||||
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.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>
|
||||
))}
|
||||
@@ -146,23 +179,43 @@ export default function AssetLibraryPage() {
|
||||
<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>}
|
||||
{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>
|
||||
<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>)}
|
||||
{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>
|
||||
<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>
|
||||
{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}
|
||||
@@ -174,7 +227,20 @@ export default function AssetLibraryPage() {
|
||||
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
||||
const cover = asset.coverUrl;
|
||||
return (
|
||||
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? (
|
||||
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -185,14 +251,22 @@ function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen
|
||||
{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.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>
|
||||
<Button size="small" onClick={onOpen}>
|
||||
查看
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -169,7 +169,22 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input.Search className="w-full" size="large" allowClear prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} />
|
||||
<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">
|
||||
@@ -178,13 +193,25 @@ export default function AssetsPage() {
|
||||
<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"); }}>
|
||||
<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
|
||||
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>
|
||||
@@ -194,15 +221,7 @@ export default function AssetsPage() {
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onOpen={() => setPreviewAsset(asset)}
|
||||
onEdit={() => openEdit(asset)}
|
||||
onCopy={copyAssetText}
|
||||
onDownload={downloadImage}
|
||||
onDelete={() => setDeletingAsset(asset)}
|
||||
/>
|
||||
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -228,7 +247,13 @@ export default function AssetsPage() {
|
||||
<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)} />
|
||||
<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="给素材起一个容易检索的名字" />
|
||||
@@ -236,7 +261,9 @@ export default function AssetsPage() {
|
||||
<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>
|
||||
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="标签">
|
||||
@@ -257,8 +284,18 @@ export default function AssetsPage() {
|
||||
) : (
|
||||
<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>}
|
||||
<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>
|
||||
)}
|
||||
@@ -266,24 +303,50 @@ export default function AssetsPage() {
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
||||
<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>}
|
||||
{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>
|
||||
<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>}
|
||||
{tags.length ? (
|
||||
tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag className="m-0">未打标签</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
<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) => {
|
||||
}}
|
||||
/>
|
||||
<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} />
|
||||
@@ -305,7 +368,11 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
||||
{cover ? (
|
||||
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
@@ -314,7 +381,9 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
<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>
|
||||
<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>
|
||||
@@ -322,17 +391,35 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
{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 || []).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>
|
||||
<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>
|
||||
);
|
||||
@@ -344,22 +431,51 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? <Image src={cover} alt={asset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
||||
{cover ? (
|
||||
<Image src={cover} alt={asset.title} className="rounded-lg" />
|
||||
) : (
|
||||
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title>
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
{asset.title}
|
||||
</Typography.Title>
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
|
||||
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||||
{(asset.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
||||
{asset.kind === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>}
|
||||
<Typography.Text type="secondary" className="block text-xs">
|
||||
内容
|
||||
</Typography.Text>
|
||||
{asset.kind === "text" ? (
|
||||
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
|
||||
) : (
|
||||
<Typography.Text className="mt-2 block">
|
||||
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{asset.note ? <div><Typography.Text type="secondary">备注</Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null}
|
||||
{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}
|
||||
{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}
|
||||
@@ -373,11 +489,5 @@ function assetSummary(asset: Asset) {
|
||||
}
|
||||
|
||||
function assetSearchText(asset: Asset) {
|
||||
return [
|
||||
asset.title,
|
||||
asset.source || "",
|
||||
asset.note || "",
|
||||
(asset.tags || []).join(" "),
|
||||
asset.kind === "text" ? asset.data.content : asset.data.mimeType,
|
||||
].join(" ").toLowerCase();
|
||||
return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,7 @@ import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"
|
||||
|
||||
export type AssetPickerTab = "my-assets" | "library";
|
||||
|
||||
export type InsertAssetPayload =
|
||||
| { kind: "text"; content: string; title: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
|
||||
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -31,15 +29,7 @@ export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onC
|
||||
}, [open, defaultTab]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择素材"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
|
||||
>
|
||||
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
||||
@@ -102,15 +92,25 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
|
||||
{[
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
].map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value || "all"}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setKindFilter(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
@@ -119,18 +119,13 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex justify-center py-16"><Spin /></div>
|
||||
<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)}
|
||||
/>
|
||||
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -157,9 +152,7 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
|
||||
{cover ? (
|
||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">{title}</div>
|
||||
)}
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -172,9 +165,7 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -229,7 +220,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{kindOptions.map((opt) => (
|
||||
@@ -237,7 +231,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
key={opt.value}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setKindFilter(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button, ConfigProvider, Input, InputNumber, Modal, Popover, Segmented, Tooltip } from "antd";
|
||||
import { Button, Modal, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
||||
@@ -17,6 +17,7 @@ import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
|
||||
|
||||
@@ -56,7 +57,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => sessions.length ? sessions : [createSession()]);
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,7 +85,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
}, [selectedNodeKey]);
|
||||
|
||||
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
|
||||
setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session));
|
||||
setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session)));
|
||||
};
|
||||
|
||||
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
|
||||
@@ -99,7 +100,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
|
||||
updateSession(sessionId, (session) => ({
|
||||
...session,
|
||||
messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message),
|
||||
messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
};
|
||||
@@ -159,7 +160,9 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
|
||||
try {
|
||||
if (nextMode === "image") {
|
||||
const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })));
|
||||
const referenceImages: ReferenceImage[] = await Promise.all(
|
||||
refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })),
|
||||
);
|
||||
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
|
||||
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
|
||||
updateMessage(session.id, assistantId, {
|
||||
@@ -224,10 +227,19 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
|
||||
>
|
||||
<motion.aside className="relative flex shrink-0 flex-col border-l" initial={{ x: 48 }} animate={{ x: closing ? 28 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<motion.aside
|
||||
className="relative flex shrink-0 flex-col border-l"
|
||||
initial={{ x: 48 }}
|
||||
animate={{ x: closing ? 28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
>
|
||||
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
|
||||
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><Sparkles className="size-4" />{view === "history" ? "历史记录" : "画布助手(未开发)"}</div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="size-4" />
|
||||
{view === "history" ? "历史记录" : "画布助手(未开发)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{view === "history" ? (
|
||||
<>
|
||||
@@ -235,7 +247,15 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除全部">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className="!h-8 !w-8 !min-w-8"
|
||||
style={iconButtonStyle}
|
||||
icon={<X className="size-4" />}
|
||||
disabled={!historySessions.length}
|
||||
onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
@@ -243,7 +263,18 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
|
||||
</Tooltip>
|
||||
<Tooltip title="新对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Plus className="size-4" />} disabled={!hasMessages} onClick={() => { startChatSession(); setView("chat"); }} />
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className="!h-8 !w-8 !min-w-8"
|
||||
style={iconButtonStyle}
|
||||
icon={<Plus className="size-4" />}
|
||||
disabled={!hasMessages}
|
||||
onClick={() => {
|
||||
startChatSession();
|
||||
setView("chat");
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="配置">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
|
||||
@@ -260,8 +291,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
sessions={historySessions}
|
||||
activeSession={activeSession}
|
||||
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
|
||||
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))}
|
||||
onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }}
|
||||
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id)))}
|
||||
onOpen={(id) => {
|
||||
setLocalActiveSessionId(id);
|
||||
setView("chat");
|
||||
}}
|
||||
onDelete={(id) => setDeleteChatIds([id])}
|
||||
/>
|
||||
) : messages.length ? (
|
||||
@@ -270,14 +304,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
|
||||
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
|
||||
<span>Infinite Canvas</span>
|
||||
<DiaTextReveal
|
||||
className="absolute inset-0"
|
||||
colors={["#A97CF8", "#F38CB8", "#FDCC92"]}
|
||||
textColor="transparent"
|
||||
duration={1.8}
|
||||
startOnView={false}
|
||||
text="Infinite Canvas"
|
||||
/>
|
||||
<DiaTextReveal className="absolute inset-0" colors={["#A97CF8", "#F38CB8", "#FDCC92"]} textColor="transparent" duration={1.8} startOnView={false} text="Infinite Canvas" />
|
||||
</div>
|
||||
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
|
||||
</div>
|
||||
@@ -309,7 +336,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
open={deleteChatIds.length > 0}
|
||||
centered
|
||||
onCancel={() => setDeleteChatIds([])}
|
||||
footer={<>
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleteChatIds([])}>取消</Button>
|
||||
<Button
|
||||
danger
|
||||
@@ -321,7 +349,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
||||
</Modal>
|
||||
@@ -363,7 +392,9 @@ function AssistantComposer({
|
||||
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{references.length ? (
|
||||
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
|
||||
{references.map((item) => <AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />)}
|
||||
{references.map((item) => (
|
||||
<AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
@@ -386,97 +417,63 @@ function AssistantComposer({
|
||||
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div className="canvas-composer-tools flex min-w-0 flex-1 items-center gap-1">
|
||||
<CanvasPromptLibrary onSelect={onPromptChange} />
|
||||
<CanvasThemeProvider theme={theme}>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-composer-mode !rounded-full !p-0.5"
|
||||
value={mode}
|
||||
onChange={(value) => onModeChange(value as AssistantMode)}
|
||||
options={[
|
||||
{ value: "ask", label: <Tooltip title="文本"><MessageSquare className="size-4" /></Tooltip> },
|
||||
{ value: "image", label: <Tooltip title="生图"><ImageIcon className="size-4" /></Tooltip> },
|
||||
]}
|
||||
/>
|
||||
</CanvasThemeProvider>
|
||||
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||
{mode === "image" ? (
|
||||
<ImageSettingsPopover config={config} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
<>
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
)}
|
||||
</div>
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10 shrink-0" icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} disabled={isRunning || !prompt.trim()} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
className="!h-10 !w-10 !min-w-10 shrink-0"
|
||||
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
|
||||
disabled={isRunning || !prompt.trim()}
|
||||
onClick={() => void onSubmit()}
|
||||
aria-label="发送"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSettingsPopover({ config, onConfigChange, onMissingConfig }: { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const quality = config.quality || "auto";
|
||||
const count = String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))));
|
||||
const sizes = ["1:1", "3:2", "2:3", "16:9", "9:16", "auto"];
|
||||
const customSize = sizes.includes(config.size || "auto") ? "" : config.size;
|
||||
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
arrow={false}
|
||||
overlayClassName="canvas-image-settings-popover"
|
||||
color={theme.toolbar.panel}
|
||||
content={(
|
||||
<CanvasThemeProvider theme={theme}>
|
||||
<div className="w-[330px] space-y-4" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-base font-semibold">图像设置</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>模型</SettingTitle>
|
||||
<ModelPicker className="!h-9 !w-full !max-w-none" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} fullWidth />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<Segmented block value={quality} onChange={(value) => onConfigChange("quality", String(value))} options={[{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{sizes.map((size) => (
|
||||
<Button key={size} size="small" type={(config.size || "auto") === size ? "primary" : "default"} className="!h-7 !rounded-md !px-1.5" onClick={() => onConfigChange("size", size)}>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
<Input size="small" className="col-span-2 !h-7" placeholder="自定义比例" value={customSize} onChange={(event) => onConfigChange("size", event.target.value.trim() || "auto")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_92px] gap-2">
|
||||
<Segmented block value={["1", "2", "3", "4"].includes(count) ? count : undefined} onChange={(value) => onConfigChange("count", String(value))} options={["1", "2", "3", "4"].map((value) => ({ value, label: `${value} 张` }))} />
|
||||
<InputNumber min={1} max={15} size="small" className="canvas-control-number !h-8 !w-full" value={Number(count)} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasThemeProvider>
|
||||
)}
|
||||
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
|
||||
{[
|
||||
{ value: "ask" as const, title: "对话", icon: <MessageSquare className="size-4" /> },
|
||||
{ value: "image" as const, title: "生图", icon: <ImageIcon className="size-4" /> },
|
||||
].map((item) => (
|
||||
<Tooltip key={item.value} title={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-composer-mode-button flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent transition"
|
||||
style={{ background: mode === item.value ? theme.node.activeStroke : "transparent", color: mode === item.value ? theme.node.panel : theme.node.text }}
|
||||
onClick={() => onChange(item.value)}
|
||||
aria-label={item.title}
|
||||
>
|
||||
<Button size="small" type="text" className="!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5" style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||
<span className="truncate">{qualityLabel(quality)} · {config.size || "auto"} · {count} 张</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel }, components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text }, Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 } } }}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||
return <div className="text-xs font-medium" style={{ color }}>{children}</div>;
|
||||
return (
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function qualityLabel(value: string) {
|
||||
@@ -500,8 +497,16 @@ function AssistantMessages({
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
|
||||
<div className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6" style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}>
|
||||
{message.role === "assistant" ? <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"><MessageSquare className="size-3.5" />回答</div> : null}
|
||||
<div
|
||||
className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6"
|
||||
style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
<MessageSquare className="size-3.5" />
|
||||
回答
|
||||
</div>
|
||||
) : null}
|
||||
{message.text}
|
||||
</div>
|
||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||
@@ -515,7 +520,14 @@ function AssistantMessages({
|
||||
{message.images?.map((image) => (
|
||||
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
|
||||
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
|
||||
<Button type="text" className="!h-8 !w-full !rounded-none" style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }} icon={<Plus className="size-3.5" />} onClick={() => onInsertImage(image)} title="插入画布" />
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-8 !w-full !rounded-none"
|
||||
style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||
icon={<Plus className="size-3.5" />}
|
||||
onClick={() => onInsertImage(image)}
|
||||
title="插入画布"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -560,7 +572,9 @@ function AssistantHistory({
|
||||
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||
return (
|
||||
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{message.references?.map((item) => <AssistantReferenceChip key={item.id} item={item} />)}
|
||||
{message.references?.map((item) => (
|
||||
<AssistantReferenceChip key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -570,9 +584,21 @@ function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantRefer
|
||||
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
||||
return (
|
||||
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
|
||||
{item.dataUrl ? <img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" /> : <span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>{text}</span>}
|
||||
{item.dataUrl ? (
|
||||
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
|
||||
) : (
|
||||
<span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{onRemove ? (
|
||||
<button type="button" className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }} onClick={onRemove} aria-label="移除引用">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
|
||||
onClick={onRemove}
|
||||
aria-label="移除引用"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -600,19 +626,21 @@ function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<
|
||||
}
|
||||
|
||||
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
|
||||
return Promise.all(messages.map(async (message, index) => {
|
||||
return Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
if (message.role === "assistant") return { role: "assistant", content: message.text };
|
||||
if (index !== messages.length - 1) return { role: "user", content: message.text };
|
||||
const refs = message.references || [];
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
...refs.flatMap((item) => item.text ? [{ type: "text" as const, text: item.text }] : []),
|
||||
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: item.text }] : [])),
|
||||
{ type: "text", text: message.text },
|
||||
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
|
||||
],
|
||||
};
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createSession(): CanvasAssistantSession {
|
||||
|
||||
@@ -73,8 +73,24 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
value={mode}
|
||||
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
|
||||
options={[
|
||||
{ value: "image", label: <span className="inline-flex items-center gap-1"><ImageIcon className="size-3.5" />生图</span> },
|
||||
{ value: "text", label: <span className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" />文本</span> },
|
||||
{
|
||||
value: "image",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ImageIcon className="size-3.5" />
|
||||
生图
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "text",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="size-3.5" />
|
||||
文本
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,12 +99,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]"
|
||||
style={chipStyle}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="size-3.5" />
|
||||
预览
|
||||
</button>
|
||||
@@ -132,7 +143,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<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} />)}
|
||||
{imageInputs.map((input, index) => (
|
||||
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
@@ -140,7 +153,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||
<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} />)}
|
||||
{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>
|
||||
@@ -149,12 +164,18 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
<Button size="small" onClick={() => setEditingTextId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={saveTextEdit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -187,7 +208,23 @@ function PreviewSection({ title, count, empty, children }: { title: string; coun
|
||||
);
|
||||
}
|
||||
|
||||
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) {
|
||||
function TextSortCard({
|
||||
input,
|
||||
textIndex,
|
||||
textTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
onEdit,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
textIndex: number;
|
||||
textTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
onEdit: (input: NodeGenerationInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
@@ -202,7 +239,21 @@ function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEd
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) {
|
||||
function ImageSortCard({
|
||||
input,
|
||||
imageIndex,
|
||||
imageTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
imageIndex: number;
|
||||
imageTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.image) return null;
|
||||
return (
|
||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
|
||||
@@ -2,19 +2,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
||||
|
||||
export function ConnectionPath({
|
||||
connection,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNodeData;
|
||||
to: CanvasNodeData;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = from.position.y + from.height / 2;
|
||||
@@ -50,15 +38,7 @@ export function ConnectionPath({
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveConnectionPath({
|
||||
node,
|
||||
handle,
|
||||
mouseWorld,
|
||||
}: {
|
||||
node?: CanvasNodeData;
|
||||
handle: ConnectionHandle;
|
||||
mouseWorld: Position;
|
||||
}) {
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (!node) return null;
|
||||
|
||||
|
||||
@@ -8,17 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ContextMenuState } from "../types";
|
||||
|
||||
export function CanvasNodeContextMenu({
|
||||
menu,
|
||||
onClose,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
menu: ContextMenuState;
|
||||
onClose: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,26 +33,11 @@ export function CanvasNodeContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger = false,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
function MenuButton({ icon, label, onClick, danger = false }: { icon: ReactNode; label: string; onClick?: () => void; danger?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80"
|
||||
style={{ color: danger ? "#f87171" : theme.node.text }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80" style={{ color: danger ? "#f87171" : theme.node.text }} onClick={onClick}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,14 @@ export function CanvasDeleteProjectsDialog() {
|
||||
open={ids.length > 0}
|
||||
centered
|
||||
onCancel={() => setDeleteIds([])}
|
||||
footer={<><Button onClick={() => setDeleteIds([])}>取消</Button><Button danger type="primary" onClick={confirm}>删除</Button></>}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleteIds([])}>取消</Button>
|
||||
<Button danger type="primary" onClick={confirm}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-stone-500">将删除 {ids.length} 个画布,里面的节点和连线也会一起移除。</p>
|
||||
</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,17 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||
|
||||
export function Minimap({
|
||||
nodes,
|
||||
viewport,
|
||||
viewportSize,
|
||||
onViewportChange,
|
||||
}: {
|
||||
nodes: CanvasNodeData[];
|
||||
viewport: ViewportTransform;
|
||||
viewportSize: { width: number; height: number };
|
||||
onViewportChange: (viewport: ViewportTransform) => void;
|
||||
}) {
|
||||
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -107,10 +97,7 @@ export function Minimap({
|
||||
};
|
||||
|
||||
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 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"
|
||||
@@ -144,10 +131,7 @@ export function Minimap({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<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 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>
|
||||
);
|
||||
|
||||
@@ -18,12 +18,7 @@ const defaultParams: CanvasImageAngleParams = {
|
||||
wideAngle: false,
|
||||
};
|
||||
|
||||
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
||||
dataUrl: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (params: CanvasImageAngleParams) => void;
|
||||
}) {
|
||||
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,7 +42,9 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
||||
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>重置</Button>
|
||||
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6 py-2">
|
||||
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
|
||||
@@ -55,32 +52,37 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
||||
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
|
||||
<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")} />
|
||||
<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>
|
||||
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
|
||||
AI 生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
suffix?: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
||||
<span className="font-medium opacity-75">{label}</span>
|
||||
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
|
||||
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
|
||||
<span className="whitespace-nowrap text-right font-semibold">
|
||||
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,17 +20,7 @@ const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
||||
const minSize = 0.06;
|
||||
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
|
||||
|
||||
export function CanvasNodeCropDialog({
|
||||
dataUrl,
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
dataUrl: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (crop: CanvasImageCropRect) => void;
|
||||
}) {
|
||||
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||
const [locked, setLocked] = useState(false);
|
||||
@@ -88,7 +78,11 @@ export function CanvasNodeCropDialog({
|
||||
<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}
|
||||
{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 ? "锁定比例" : "自由比例"}
|
||||
@@ -97,8 +91,12 @@ export function CanvasNodeCropDialog({
|
||||
|
||||
<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>
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -17,14 +17,12 @@ export type NodeGenerationInput = {
|
||||
image?: ReferenceImage;
|
||||
};
|
||||
|
||||
export function buildNodeGenerationContext(
|
||||
nodeId: string,
|
||||
nodes: CanvasNodeData[],
|
||||
connections: CanvasConnection[],
|
||||
prompt: string,
|
||||
): NodeGenerationContext {
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
|
||||
const 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 {
|
||||
@@ -35,11 +33,7 @@ export function buildNodeGenerationContext(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeGenerationInputs(
|
||||
nodeId: string,
|
||||
nodes: CanvasNodeData[],
|
||||
connections: CanvasConnection[],
|
||||
): NodeGenerationInput[] {
|
||||
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||
const image = readReferenceImage(node);
|
||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||
@@ -57,10 +51,7 @@ export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompl
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text" as const, text: context.prompt },
|
||||
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
|
||||
],
|
||||
content: [{ type: "text" as const, text: context.prompt }, ...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } }))],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -93,8 +84,5 @@ function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connec
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const order = target?.metadata?.inputOrder || [];
|
||||
return [
|
||||
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
...upstreamNodes.filter((node) => !order.includes(node.id)),
|
||||
];
|
||||
return [...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 { 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 { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
@@ -25,6 +25,7 @@ type CanvasNodeHoverToolbarProps = {
|
||||
onSaveAsset: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
@@ -46,6 +47,7 @@ export function CanvasNodeHoverToolbar({
|
||||
onSaveAsset,
|
||||
onCrop,
|
||||
onAngle,
|
||||
onViewImage,
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
@@ -84,9 +86,18 @@ export function CanvasNodeHoverToolbar({
|
||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"} label={node.metadata?.freeResize ? "自由比例" : "锁比例"} icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />} onClick={() => onToggleFreeResize(node)} active={node.metadata?.freeResize} /> : null}
|
||||
{hasImage ? (
|
||||
<ToolbarAction
|
||||
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
|
||||
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
|
||||
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
|
||||
onClick={() => onToggleFreeResize(node)}
|
||||
active={node.metadata?.freeResize}
|
||||
/>
|
||||
) : null}
|
||||
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,13 +109,17 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
||||
const json = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return JSON.stringify(node, (key, value) => {
|
||||
return JSON.stringify(
|
||||
node,
|
||||
(key, value) => {
|
||||
if (key === "title") return undefined;
|
||||
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
||||
return "[base64 image]";
|
||||
}
|
||||
return value;
|
||||
}, 2);
|
||||
},
|
||||
2,
|
||||
);
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,7 +155,11 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
{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}
|
||||
{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 }}>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, LoaderCircle } from "lucide-react";
|
||||
import { Button, InputNumber } from "antd";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasSizePicker } from "./canvas-size-picker";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
||||
|
||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||
@@ -20,9 +20,10 @@ type CanvasNodePromptPanelProps = {
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
||||
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -67,19 +68,27 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
}}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
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="flex min-w-0 items-center gap-2">
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
{mode === "image" ? (
|
||||
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} />
|
||||
) : null}
|
||||
{mode === "image" ? (
|
||||
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 shrink-0 !w-[58px]" value={Math.floor(Math.abs(Number(config.count)) || 1)} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
|
||||
) : null}
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => openConfigDialog(true)}
|
||||
onOpenChange={onImageSettingsOpenChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -104,7 +113,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
return {
|
||||
...globalConfig,
|
||||
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,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
|
||||
@@ -264,18 +264,16 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
>
|
||||
<div
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={{
|
||||
style={
|
||||
{
|
||||
background: hasImageContent ? "transparent" : theme.node.fill,
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
animation: data.metadata?.batchRootId
|
||||
? batchClosing
|
||||
? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both"
|
||||
: "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both"
|
||||
: undefined,
|
||||
animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined,
|
||||
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
|
||||
} as React.CSSProperties}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<NodeContent
|
||||
node={data}
|
||||
@@ -297,12 +295,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasImageContent ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
|
||||
style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }}
|
||||
/>
|
||||
) : null}
|
||||
{!hasImageContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||
|
||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||
@@ -310,16 +303,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||||
</div>
|
||||
|
||||
<ConnectionHandleDot
|
||||
side="left"
|
||||
visible={hovered || isSelected || isConnecting}
|
||||
onMouseDown={(event) => onConnectStart(event, data.id, "target")}
|
||||
/>
|
||||
<ConnectionHandleDot
|
||||
side="right"
|
||||
visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)}
|
||||
onMouseDown={(event) => onConnectStart(event, data.id, "source")}
|
||||
/>
|
||||
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
||||
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
||||
|
||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
@@ -351,11 +336,7 @@ function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
node,
|
||||
theme,
|
||||
onRetry,
|
||||
}: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
||||
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
||||
return (
|
||||
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
||||
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
|
||||
@@ -376,15 +357,7 @@ function ErrorContent({
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({
|
||||
node,
|
||||
theme,
|
||||
isEditingContent,
|
||||
textareaRef,
|
||||
onContentChange,
|
||||
onStopEditing,
|
||||
onGenerateImage,
|
||||
}: NodeContentRendererProps) {
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||
<button
|
||||
@@ -433,12 +406,19 @@ function TextContent({
|
||||
|
||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||
const content = props.node.metadata?.status === "loading"
|
||||
? <LoadingContent theme={props.theme} />
|
||||
: props.node.metadata?.status === "error"
|
||||
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
|
||||
: <EmptyImageContent {...props} isBatchRoot={false} />;
|
||||
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
|
||||
const content =
|
||||
props.node.metadata?.status === "loading" ? (
|
||||
<LoadingContent theme={props.theme} />
|
||||
) : props.node.metadata?.status === "error" ? (
|
||||
<ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
|
||||
) : (
|
||||
<EmptyImageContent {...props} isBatchRoot={false} />
|
||||
);
|
||||
return (
|
||||
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>
|
||||
{content}
|
||||
</BatchFrame>
|
||||
);
|
||||
}
|
||||
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
|
||||
|
||||
@@ -465,10 +445,13 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
|
||||
<span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>
|
||||
</div>
|
||||
);
|
||||
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>{content}</BatchFrame>;
|
||||
if (isBatchRoot)
|
||||
return (
|
||||
content
|
||||
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
|
||||
{content}
|
||||
</BatchFrame>
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
function ImageContent({
|
||||
@@ -545,7 +528,17 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const isBatchRoot = batchCount > 1;
|
||||
return (
|
||||
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}>
|
||||
<div
|
||||
className="group/batch relative h-full w-full overflow-visible"
|
||||
onDoubleClick={
|
||||
isBatchRoot
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isBatchRoot ? (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-visible">
|
||||
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
|
||||
@@ -557,7 +550,8 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
|
||||
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
|
||||
borderColor: theme.node.stroke,
|
||||
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
|
||||
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
|
||||
transform:
|
||||
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
|
||||
zIndex: -index - 1,
|
||||
}}
|
||||
/>
|
||||
@@ -568,13 +562,7 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ResizeHandle({
|
||||
corner,
|
||||
onMouseDown,
|
||||
}: {
|
||||
corner: ResizeCorner;
|
||||
onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void;
|
||||
}) {
|
||||
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
|
||||
const positionClass = {
|
||||
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
|
||||
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
|
||||
@@ -582,23 +570,10 @@ function ResizeHandle({
|
||||
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
|
||||
}[corner];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute z-50 size-7 ${positionClass}`}
|
||||
onMouseDown={(event) => onMouseDown(event, corner)}
|
||||
/>
|
||||
);
|
||||
return <div className={`absolute z-50 size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
|
||||
}
|
||||
|
||||
function ConnectionHandleDot({
|
||||
side,
|
||||
visible,
|
||||
onMouseDown,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
visible: boolean;
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
}) {
|
||||
function ConnectionHandleDot({ side, visible, onMouseDown }: { side: "left" | "right"; visible: boolean; onMouseDown: (event: React.MouseEvent) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
@@ -608,10 +583,7 @@ function ConnectionHandleDot({
|
||||
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className="size-3 rounded-full border-2 transition-all hover:scale-125"
|
||||
style={{ background: theme.node.panel, borderColor: theme.node.muted }}
|
||||
/>
|
||||
<div className="size-3 rounded-full border-2 transition-all hover:scale-125" style={{ background: theme.node.panel, borderColor: theme.node.muted }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,18 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
|
||||
{editing ? (
|
||||
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
||||
) : (
|
||||
<button type="button" className="min-w-0 cursor-pointer text-left" onClick={(event) => { event.stopPropagation(); open(); }}>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 cursor-pointer text-left"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} 个节点 · {project.connections.length} 条连线</p>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
|
||||
{project.nodes.length} 个节点 · {project.connections.length} 条连线
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,14 @@ export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) =
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="提示词库">
|
||||
<Button shape="circle" className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent" style={{ borderColor: theme.node.stroke, color: theme.node.text }} icon={<BookOpen className="size-3.5" />} onClick={() => setOpen(true)} aria-label="提示词库" />
|
||||
<Button
|
||||
type="text"
|
||||
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} />
|
||||
</>
|
||||
|
||||
@@ -47,7 +47,11 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
|
||||
placeholder="比例"
|
||||
options={options}
|
||||
popupMatchSelectWidth={false}
|
||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
||||
popupRender={(menu) => (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={setOpen}
|
||||
onSearch={setSearch}
|
||||
onChange={selectSize}
|
||||
|
||||
@@ -58,11 +58,7 @@ export function CanvasToolbar({
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0"
|
||||
style={dockStyle}
|
||||
>
|
||||
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
||||
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||
<Hand className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
@@ -146,14 +142,35 @@ export function CanvasToolbar({
|
||||
value={backgroundMode}
|
||||
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
|
||||
options={[
|
||||
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-4" />点</span> },
|
||||
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-4" />线</span> },
|
||||
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" />空白</span> },
|
||||
{
|
||||
value: "dots",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CircleDot className="size-4" />点
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "lines",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Grid2x2 className="size-4" />线
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "blank",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Square className="size-4" />
|
||||
空白
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -197,7 +214,10 @@ function ToolbarButton({
|
||||
disabled={disabled}
|
||||
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
|
||||
icon={children}
|
||||
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }}
|
||||
onMouseEnter={(event) => {
|
||||
onHover(id);
|
||||
onTipX(getTipX(wrapRef.current, event.currentTarget));
|
||||
}}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
@@ -208,17 +228,7 @@ function Divider({ theme }: { theme: CanvasTheme }) {
|
||||
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
|
||||
}
|
||||
|
||||
function CanvasThemeButton({
|
||||
colorTheme,
|
||||
targetTheme,
|
||||
onThemeChange,
|
||||
children,
|
||||
}: {
|
||||
colorTheme: CanvasColorTheme;
|
||||
targetTheme: CanvasColorTheme;
|
||||
onThemeChange: (theme: CanvasColorTheme) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const active = colorTheme === targetTheme;
|
||||
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
@@ -239,7 +249,11 @@ function CanvasThemeButton({
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -25,7 +25,14 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
|
||||
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
|
||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} icon={<Compass className="size-4" />} onClick={onToggleMiniMap} aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} />
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-8 !w-8 !min-w-8 !p-0"
|
||||
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
|
||||
icon={<Compass className="size-4" />}
|
||||
onClick={onToggleMiniMap}
|
||||
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置视图">
|
||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
|
||||
|
||||
@@ -18,17 +18,7 @@ type InfiniteCanvasProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({
|
||||
containerRef,
|
||||
viewport,
|
||||
backgroundMode = "lines",
|
||||
onViewportChange,
|
||||
onCanvasMouseDown,
|
||||
onCanvasDeselect,
|
||||
onContextMenu,
|
||||
onDrop,
|
||||
children,
|
||||
}: InfiniteCanvasProps) {
|
||||
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const panState = useRef({
|
||||
isPanning: false,
|
||||
@@ -47,9 +37,12 @@ export function InfiniteCanvas({
|
||||
scaleRef.current = viewport.k;
|
||||
}, [viewport.k]);
|
||||
|
||||
useEffect(() => () => {
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -72,7 +65,7 @@ export function InfiniteCanvas({
|
||||
|
||||
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;
|
||||
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);
|
||||
@@ -94,6 +87,7 @@ export function InfiniteCanvas({
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
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]");
|
||||
|
||||
@@ -205,9 +199,8 @@ function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: Can
|
||||
const x = viewport.x % gridSize;
|
||||
const y = viewport.y % gridSize;
|
||||
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
|
||||
const backgroundImage = mode === "dots"
|
||||
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)`
|
||||
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
|
||||
const backgroundImage =
|
||||
mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -54,10 +54,22 @@ export default function CanvasPage() {
|
||||
<h1 className="mt-3 text-3xl font-semibold">无限画布</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除选中</Button> : null}
|
||||
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>删除全部</Button> : null}
|
||||
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>导入画布</Button>
|
||||
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
||||
{selectedIds.length ? (
|
||||
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
|
||||
删除选中
|
||||
</Button>
|
||||
) : null}
|
||||
{projects.length ? (
|
||||
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
|
||||
删除全部
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
|
||||
导入画布
|
||||
</Button>
|
||||
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -73,7 +85,9 @@ export default function CanvasPage() {
|
||||
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
|
||||
<h2 className="text-xl font-medium">还没有画布</h2>
|
||||
<p className="mt-3 text-sm text-stone-500">新建一个画布后,就可以独立保存节点、连线和画布外观。</p>
|
||||
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
||||
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||
新建画布
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -100,21 +100,25 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
openProject: (id) => {
|
||||
return get().projects.find((item) => item.id === id) || null;
|
||||
},
|
||||
renameProject: (id, title) => set((state) => ({
|
||||
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project),
|
||||
renameProject: (id, title) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
|
||||
})),
|
||||
deleteProjects: (ids) => set((state) => {
|
||||
deleteProjects: (ids) =>
|
||||
set((state) => {
|
||||
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
||||
return { projects };
|
||||
}),
|
||||
updateProject: (id, patch) => set((state) => ({
|
||||
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project),
|
||||
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) => ({
|
||||
partialize: (state) =>
|
||||
({
|
||||
projects: state.projects,
|
||||
}) as StorageValue<CanvasStore>["state"],
|
||||
onRehydrateStorage: () => () => {
|
||||
|
||||
@@ -111,8 +111,7 @@ export type SelectionBox = {
|
||||
initialSelectedNodeIds: string[];
|
||||
};
|
||||
|
||||
export type ContextMenuState =
|
||||
{
|
||||
export type ContextMenuState = {
|
||||
type: "node";
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -17,13 +17,7 @@ export type ImageAngleTransform = {
|
||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||
const image = await loadImage(dataUrl);
|
||||
if (crop) {
|
||||
return drawCrop(
|
||||
image,
|
||||
Math.floor(crop.x * image.width),
|
||||
Math.floor(crop.y * image.height),
|
||||
Math.ceil(crop.width * image.width),
|
||||
Math.ceil(crop.height * image.height),
|
||||
);
|
||||
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
|
||||
}
|
||||
const size = Math.min(image.width, image.height);
|
||||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||||
|
||||
@@ -96,10 +96,12 @@ export default function ImagePage() {
|
||||
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
const nextReferences = await Promise.all(imageFiles.map(async (file) => {
|
||||
const nextReferences = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const image = await uploadImage(file);
|
||||
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences]);
|
||||
};
|
||||
|
||||
@@ -111,10 +113,12 @@ export default function ImagePage() {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
return;
|
||||
}
|
||||
const nextReferences = await Promise.all(blobs.map(async (blob, index) => {
|
||||
const nextReferences = await Promise.all(
|
||||
blobs.map(async (blob, index) => {
|
||||
const image = await uploadImage(blob);
|
||||
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences]);
|
||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||
} catch {
|
||||
@@ -147,15 +151,24 @@ export default function ImagePage() {
|
||||
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
|
||||
|
||||
const result = await Promise.allSettled(tasks);
|
||||
const successImages = result
|
||||
.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled")
|
||||
.map((item) => item.value);
|
||||
const successImages = result.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled").map((item) => item.value);
|
||||
const successCount = successImages.length;
|
||||
const failCount = generationCount - successCount;
|
||||
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||
|
||||
try {
|
||||
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) }));
|
||||
saveLog(
|
||||
buildLog({
|
||||
prompt: text,
|
||||
model,
|
||||
config: { ...snapshot.config, count: String(generationCount) },
|
||||
durationMs: performance.now() - batchStartedAt,
|
||||
successCount,
|
||||
failCount,
|
||||
status: successCount ? "成功" : "失败",
|
||||
thumbnails: successImages.map((image) => image.dataUrl),
|
||||
}),
|
||||
);
|
||||
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -228,10 +241,12 @@ export default function ImagePage() {
|
||||
const previewGenerationLog = async (log: GenerationLog) => {
|
||||
setPreviewLog(log);
|
||||
setLogsOpen(false);
|
||||
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => {
|
||||
const images = await Promise.all(
|
||||
log.thumbnails.map(async (dataUrl, index) => {
|
||||
const meta = await readImageMeta(dataUrl);
|
||||
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
|
||||
};
|
||||
|
||||
@@ -277,7 +292,15 @@ export default function ImagePage() {
|
||||
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
|
||||
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
||||
<LogPanel
|
||||
logs={logs}
|
||||
selectedLogIds={selectedLogIds}
|
||||
activeLogId={previewLog?.id}
|
||||
onSelectedLogIdsChange={setSelectedLogIds}
|
||||
onCreateSession={createSession}
|
||||
onDeleteSelected={() => setDeleteConfirmOpen(true)}
|
||||
onPreviewLog={(log) => void previewGenerationLog(log)}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
@@ -288,8 +311,12 @@ export default function ImagePage() {
|
||||
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">生图工作台</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2 lg:hidden">
|
||||
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>记录</Button>
|
||||
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>参数</Button>
|
||||
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
|
||||
记录
|
||||
</Button>
|
||||
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
参数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,35 +326,46 @@ export default function ImagePage() {
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">提示词</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>查看提示词库</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>查看我的素材</Button>
|
||||
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="描述画面主体、风格、构图、光线和用途"
|
||||
/>
|
||||
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">参考图</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>剪切板</Button>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>上传</Button>
|
||||
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
|
||||
剪切板
|
||||
</Button>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => {
|
||||
<div
|
||||
className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"
|
||||
onWheel={(event) => {
|
||||
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.scrollLeft += event.deltaY;
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{references.map((item) => (
|
||||
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
|
||||
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
|
||||
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex"
|
||||
onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))}
|
||||
aria-label="移除参考图"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -337,8 +375,12 @@ export default function ImagePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {effectiveConfig.size} · {effectiveConfig.quality}</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>调整</Button>
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||
{model} · {effectiveConfig.size} · {effectiveConfig.quality}
|
||||
</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
调整
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
|
||||
@@ -362,15 +404,15 @@ export default function ImagePage() {
|
||||
</div>
|
||||
{results.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{results.map((result, index) => (
|
||||
{results.map((result, index) =>
|
||||
result.status === "success" && result.image ? (
|
||||
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
|
||||
) : result.status === "failed" ? (
|
||||
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
|
||||
) : (
|
||||
<PendingImageCard key={result.id} />
|
||||
)
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
|
||||
@@ -381,12 +423,27 @@ export default function ImagePage() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void addReferences(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
||||
<LogPanel
|
||||
logs={logs}
|
||||
selectedLogIds={selectedLogIds}
|
||||
activeLogId={previewLog?.id}
|
||||
onSelectedLogIdsChange={setSelectedLogIds}
|
||||
onCreateSession={createSession}
|
||||
onDeleteSelected={() => setDeleteConfirmOpen(true)}
|
||||
onPreviewLog={(log) => void previewGenerationLog(log)}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -425,20 +482,40 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void }) {
|
||||
function ResultImageCard({
|
||||
image,
|
||||
index,
|
||||
onEdit,
|
||||
onDownload,
|
||||
onSaveAsset,
|
||||
}: {
|
||||
image: GeneratedImage;
|
||||
index: number;
|
||||
onEdit: (image: GeneratedImage, index: number) => void;
|
||||
onDownload: (image: GeneratedImage, index: number) => void;
|
||||
onSaveAsset: (image: GeneratedImage, index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
||||
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
|
||||
<span>{image.width}x{image.height}</span>
|
||||
<span>
|
||||
{image.width}x{image.height}
|
||||
</span>
|
||||
<span>{formatBytes(image.bytes)}</span>
|
||||
<span>{formatDuration(image.durationMs)}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>添加到素材</Button>
|
||||
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>加入参考图</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>下载</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||
添加到素材
|
||||
</Button>
|
||||
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>
|
||||
加入参考图
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,17 +550,35 @@ function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => voi
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
|
||||
<Button size="small" danger onClick={onRetry}>重试</Button>
|
||||
<Button size="small" danger onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
|
||||
return results.map((item, itemIndex) => itemIndex === index ? { ...item, ...next } : item);
|
||||
return results.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
|
||||
}
|
||||
|
||||
function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void }) {
|
||||
function LogPanel({
|
||||
logs,
|
||||
selectedLogIds,
|
||||
activeLogId,
|
||||
onSelectedLogIdsChange,
|
||||
onCreateSession,
|
||||
onDeleteSelected,
|
||||
onPreviewLog,
|
||||
}: {
|
||||
logs: GenerationLog[];
|
||||
selectedLogIds: string[];
|
||||
activeLogId?: string;
|
||||
onSelectedLogIdsChange: (ids: string[]) => void;
|
||||
onCreateSession: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onPreviewLog: (log: GenerationLog) => void;
|
||||
}) {
|
||||
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
|
||||
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
|
||||
|
||||
@@ -496,9 +591,15 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
||||
<Tag className="m-0">{logs.length}</Tag>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>新建</Button>
|
||||
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>删除</Button>
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
|
||||
新建
|
||||
</Button>
|
||||
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
|
||||
{allSelected ? "取消" : "全选"}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{logs.map((log) => (
|
||||
@@ -511,11 +612,7 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
||||
onClick={() => onPreviewLog(log)}
|
||||
/>
|
||||
))}
|
||||
{!logs.length ? (
|
||||
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
|
||||
暂无生成记录
|
||||
</div>
|
||||
) : null}
|
||||
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">暂无生成记录</div> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -523,7 +620,11 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
||||
|
||||
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
|
||||
<button
|
||||
type="button"
|
||||
className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
|
||||
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
|
||||
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
|
||||
@@ -531,19 +632,29 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
|
||||
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
|
||||
{log.thumbnails?.length ? (
|
||||
<div className="mt-2 flex gap-1 overflow-hidden">
|
||||
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)}
|
||||
{log.thumbnails.slice(0, 4).map((image, index) => (
|
||||
<img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid justify-items-end gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">成功 {log.successCount ?? log.imageCount}</Tag>
|
||||
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">失败 {log.failCount}</Tag> : null}
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">
|
||||
成功 {log.successCount ?? log.imageCount}
|
||||
</Tag>
|
||||
{log.failCount ? (
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">
|
||||
失败 {log.failCount}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} 张</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
|
||||
{formatDuration(log.durationMs)}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
|
||||
@@ -563,20 +674,21 @@ async function readStoredLogs() {
|
||||
window.localStorage.removeItem(LOG_STORE_KEY);
|
||||
}
|
||||
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
|
||||
await Promise.all(legacyKeys.map(async (key) => {
|
||||
await Promise.all(
|
||||
legacyKeys.map(async (key) => {
|
||||
try {
|
||||
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
|
||||
await logStore.setItem(log.id, log);
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const logs: GenerationLog[] = [];
|
||||
await logStore.iterate<GenerationLog, void>((value) => {
|
||||
logs.push(normalizeLog(value));
|
||||
});
|
||||
return logs
|
||||
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -600,7 +712,25 @@ function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog {
|
||||
function buildLog({
|
||||
prompt,
|
||||
model,
|
||||
config,
|
||||
durationMs,
|
||||
successCount,
|
||||
failCount,
|
||||
status,
|
||||
thumbnails,
|
||||
}: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
config: { size: string; quality: string; count?: string };
|
||||
durationMs: number;
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
status: GenerationLog["status"];
|
||||
thumbnails: string[];
|
||||
}): GenerationLog {
|
||||
return {
|
||||
id: nanoid(),
|
||||
createdAt: Date.now(),
|
||||
|
||||
+18
-18
@@ -8,15 +8,7 @@ import { fetchPrompts, type Prompt } from "@/services/api/prompts";
|
||||
import { navigationTools } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Highlighter({
|
||||
action,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
action: "highlight" | "underline";
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="relative inline-block px-1">
|
||||
{action === "highlight" ? (
|
||||
@@ -37,7 +29,9 @@ export default function IndexPage() {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPrompts({ pageSize: 12 }).then((data) => setPromptShowcase(data.items)).catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
||||
void fetchPrompts({ pageSize: 12 })
|
||||
.then((data) => setPromptShowcase(data.items))
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
@@ -47,11 +41,17 @@ export default function IndexPage() {
|
||||
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||
|
||||
<div className="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>
|
||||
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">无限画布</h1>
|
||||
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
|
||||
在<Highlighter action="underline" color="#FF9800">无限画布</Highlighter>中生成、连接和重组<Highlighter action="highlight" color="#87CEFA">图片、文字与图形</Highlighter>,让创作从单次生成变成连续推演。
|
||||
在
|
||||
<Highlighter action="underline" color="#FF9800">
|
||||
无限画布
|
||||
</Highlighter>
|
||||
中生成、连接和重组
|
||||
<Highlighter action="highlight" color="#87CEFA">
|
||||
图片、文字与图形
|
||||
</Highlighter>
|
||||
,让创作从单次生成变成连续推演。
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
@@ -68,9 +68,7 @@ export default function IndexPage() {
|
||||
<div />
|
||||
<div className="max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">沉淀每一次好结果</h2>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">
|
||||
收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。
|
||||
</p>
|
||||
<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">
|
||||
查看提示词库
|
||||
@@ -117,7 +115,9 @@ export default function IndexPage() {
|
||||
}}
|
||||
>
|
||||
<div className="hidden">
|
||||
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)}
|
||||
{promptShowcase.map((item) => (
|
||||
<Image key={item.id} src={item.coverUrl} alt={item.title} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</main>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PromptsPage() {
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
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) => {
|
||||
@@ -47,13 +47,20 @@ export default function PromptsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
|
||||
<main
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
|
||||
onScroll={handleListScroll}
|
||||
>
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
||||
</div>
|
||||
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-60 items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
) : null}
|
||||
{!query.isLoading ? (
|
||||
<>
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
@@ -74,7 +81,12 @@ export default function PromptsPage() {
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||
<div className="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.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>
|
||||
))}
|
||||
@@ -89,11 +101,23 @@ export default function PromptsPage() {
|
||||
<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>} />
|
||||
<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 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>
|
||||
|
||||
+79
-17
@@ -141,8 +141,7 @@
|
||||
|
||||
@layer utilities {
|
||||
.ai-title-aurora {
|
||||
background:
|
||||
linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
|
||||
background: linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
@@ -151,15 +150,15 @@
|
||||
}
|
||||
|
||||
.dark .ai-title-aurora {
|
||||
background:
|
||||
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
|
||||
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% {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
|
||||
}
|
||||
@@ -186,7 +185,11 @@
|
||||
color: #57534e;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.prompt-filter-tag.ant-tag-checkable:hover {
|
||||
@@ -215,7 +218,9 @@
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
@@ -314,6 +319,71 @@
|
||||
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;
|
||||
@@ -329,14 +399,6 @@
|
||||
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);
|
||||
@@ -396,10 +458,10 @@
|
||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className="bg-background text-foreground antialiased"
|
||||
style={{
|
||||
fontFamily:
|
||||
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||
fontFamily: '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||
}}
|
||||
>
|
||||
<Script
|
||||
|
||||
@@ -6,19 +6,18 @@ export default function NotFound() {
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
||||
404
|
||||
</div>
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">
|
||||
这个地址没有对应的页面,可能已经移动或被合并到其他入口。
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">这个地址没有对应的页面,可能已经移动或被合并到其他入口。</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
<Link href="/login" className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800"
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
去登录
|
||||
</Link>
|
||||
|
||||
@@ -54,12 +54,21 @@ export function AppConfigModal() {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<div><div className="text-lg font-semibold">配置</div><div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div></div>}
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div>
|
||||
</div>
|
||||
}
|
||||
open={isConfigOpen}
|
||||
width={760}
|
||||
centered
|
||||
onCancel={() => setConfigDialogOpen(false)}
|
||||
footer={<Button type="primary" onClick={finishConfig}>完成</Button>}
|
||||
footer={
|
||||
<Button type="primary" onClick={finishConfig}>
|
||||
完成
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
@@ -70,7 +79,10 @@ export function AppConfigModal() {
|
||||
size="middle"
|
||||
value={effectiveMode}
|
||||
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
|
||||
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
|
||||
options={[
|
||||
{ label: "本地直连", value: "local" },
|
||||
{ label: "云端渠道", value: "remote" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
@@ -89,7 +101,9 @@ export function AppConfigModal() {
|
||||
<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>
|
||||
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
|
||||
拉取模型列表
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -35,10 +35,7 @@ export function AppTopNav() {
|
||||
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300"
|
||||
>
|
||||
<Link href="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300">
|
||||
<span
|
||||
className="size-5 shrink-0 bg-current"
|
||||
style={{
|
||||
@@ -117,7 +114,6 @@ export function AppTopNav() {
|
||||
|
||||
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
||||
<AppConfigModal />
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
|
||||
active
|
||||
? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100"
|
||||
: "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
|
||||
active ? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
|
||||
@@ -25,15 +25,7 @@ type UserStatusActionsProps = {
|
||||
getPopupContainer?: (node: HTMLElement) => HTMLElement;
|
||||
};
|
||||
|
||||
export function UserStatusActions({
|
||||
showConfig = true,
|
||||
variant = "default",
|
||||
onOpenShortcuts,
|
||||
accountOpen,
|
||||
onAccountOpenChange,
|
||||
accountRef,
|
||||
getPopupContainer,
|
||||
}: UserStatusActionsProps) {
|
||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
@@ -59,37 +51,15 @@ export function UserStatusActions({
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1.5">
|
||||
{showConfig ? (
|
||||
<button
|
||||
type="button"
|
||||
className={naturalIconClass}
|
||||
style={iconStyle}
|
||||
onClick={() => openConfigDialog(false)}
|
||||
aria-label="配置"
|
||||
title="配置"
|
||||
>
|
||||
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
|
||||
<Settings2 className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<AnimatedThemeToggler
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
className={naturalIconClass}
|
||||
style={iconStyle}
|
||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
/>
|
||||
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
|
||||
<VersionReleaseModal style={versionStyle} />
|
||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||
<div ref={accountRef}>
|
||||
<Dropdown
|
||||
open={accountOpen}
|
||||
onOpenChange={onAccountOpenChange}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
getPopupContainer={getPopupContainer}
|
||||
styles={{ root: { minWidth: 150 } }}
|
||||
menu={{ items: menuItems }}
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -39,14 +39,7 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
|
||||
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
<Modal
|
||||
title="版本更新"
|
||||
open={open}
|
||||
width={680}
|
||||
centered
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<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>
|
||||
@@ -82,7 +75,9 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"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";
|
||||
|
||||
type ModelPickerProps = {
|
||||
@@ -15,27 +18,79 @@ type 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 width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const options = useMemo(() => Array.from(new Set([value, ...config.models].filter(Boolean))), [config.models, value]);
|
||||
const current = value || "";
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
className={`canvas-control-select ${className || ""}`}
|
||||
popupMatchSelectWidth={false}
|
||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
||||
value={value || undefined}
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
notFoundContent="请先到配置里拉取模型列表"
|
||||
onChange={onChange}
|
||||
open={open}
|
||||
value={current}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen && !options.length) {
|
||||
onMissingConfig?.();
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"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",
|
||||
"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()}
|
||||
onClick={() => {
|
||||
if (!options.length) onMissingConfig?.();
|
||||
}}
|
||||
filterOption={(input, option) => String(option?.label || "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
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 "";
|
||||
}
|
||||
|
||||
@@ -6,13 +6,33 @@ import { Button, Card, Tag } from "antd";
|
||||
|
||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actionIcon = <Copy className="size-3.5" />, actionType = "text", extraAction }: { item: Prompt; onOpen: () => void; onCopy: () => void; actionLabel?: string; actionIcon?: ReactNode; actionType?: "text" | "primary"; extraAction?: ReactNode }) {
|
||||
export function PromptCard({
|
||||
item,
|
||||
onOpen,
|
||||
onCopy,
|
||||
actionLabel = "复制",
|
||||
actionIcon = <Copy className="size-3.5" />,
|
||||
actionType = "text",
|
||||
extraAction,
|
||||
}: {
|
||||
item: Prompt;
|
||||
onOpen: () => void;
|
||||
onCopy: () => void;
|
||||
actionLabel?: string;
|
||||
actionIcon?: ReactNode;
|
||||
actionType?: "text" | "primary";
|
||||
extraAction?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={<button type="button" className="block w-full text-left" onClick={onOpen}><img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /></button>}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
@@ -22,12 +42,18 @@ export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actio
|
||||
</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>)}
|
||||
{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>
|
||||
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{extraAction}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -17,12 +17,26 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1.5">{prompt.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}</div>
|
||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
|
||||
创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}
|
||||
</div>
|
||||
<Space wrap className="mt-5">
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>复制提示词</Button>
|
||||
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>加入我的素材</Button> : null}
|
||||
<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>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
||||
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
|
||||
const toggleTag = (tag: string) => {
|
||||
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) => {
|
||||
onSelect(prompt);
|
||||
@@ -65,14 +65,22 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
||||
</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}
|
||||
{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}
|
||||
{query.isFetchingNextPage ? (
|
||||
<div className="py-4 text-center">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function usePromptList({ keyword, tags, category, enabled = true }: { key
|
||||
queryKey: ["prompts", keyword, tags, category],
|
||||
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined,
|
||||
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
|
||||
enabled,
|
||||
});
|
||||
const firstPage = query.data?.pages[0];
|
||||
|
||||
@@ -1,241 +1,172 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
export type TransitionVariant = "circle" | "square" | "triangle" | "diamond" | "hexagon" | "rectangle" | "star";
|
||||
|
||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
duration?: number
|
||||
variant?: TransitionVariant
|
||||
duration?: number;
|
||||
variant?: TransitionVariant;
|
||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
||||
fromCenter?: boolean
|
||||
theme?: "light" | "dark"
|
||||
targetTheme?: "light" | "dark"
|
||||
onThemeChange?: (theme: "light" | "dark") => void
|
||||
fromCenter?: boolean;
|
||||
theme?: "light" | "dark";
|
||||
targetTheme?: "light" | "dark";
|
||||
onThemeChange?: (theme: "light" | "dark") => void;
|
||||
}
|
||||
|
||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||
const pairs = Array.from(
|
||||
{ length: vertexCount },
|
||||
() => `${cx}px ${cy}px`
|
||||
).join(", ")
|
||||
return `polygon(${pairs})`
|
||||
const pairs = Array.from({ length: vertexCount }, () => `${cx}px ${cy}px`).join(", ");
|
||||
return `polygon(${pairs})`;
|
||||
}
|
||||
|
||||
function getThemeTransitionClipPaths(
|
||||
variant: TransitionVariant,
|
||||
cx: number,
|
||||
cy: number,
|
||||
maxRadius: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): [string, string] {
|
||||
function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy: number, maxRadius: number, viewportWidth: number, viewportHeight: number): [string, string] {
|
||||
switch (variant) {
|
||||
case "circle":
|
||||
return [
|
||||
`circle(0px at ${cx}px ${cy}px)`,
|
||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
||||
]
|
||||
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})`]
|
||||
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})`]
|
||||
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})`]
|
||||
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[] = []
|
||||
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`)
|
||||
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(", ")})`]
|
||||
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})`]
|
||||
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 R = maxRadius * Math.SQRT2 * 1.03;
|
||||
const innerRatio = 0.42;
|
||||
const starPolygon = (radius: number) => {
|
||||
const verts: string[] = []
|
||||
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`
|
||||
)
|
||||
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)]
|
||||
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)`,
|
||||
]
|
||||
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
||||
}
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
children,
|
||||
className,
|
||||
duration = 400,
|
||||
variant,
|
||||
fromCenter = false,
|
||||
theme,
|
||||
targetTheme,
|
||||
onThemeChange,
|
||||
...props
|
||||
}: AnimatedThemeTogglerProps) => {
|
||||
const shape = variant ?? "circle"
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => {
|
||||
const shape = variant ?? "circle";
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setIsDark(theme === "dark")
|
||||
return
|
||||
setIsDark(theme === "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
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, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
});
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [theme])
|
||||
return () => observer.disconnect();
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
|
||||
let x: number
|
||||
let y: number
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (fromCenter) {
|
||||
x = viewportWidth / 2
|
||||
y = viewportHeight / 2
|
||||
x = viewportWidth / 2;
|
||||
y = viewportHeight / 2;
|
||||
} else {
|
||||
const { top, left, width, height } = button.getBoundingClientRect()
|
||||
x = left + width / 2
|
||||
y = top + height / 2
|
||||
const { top, left, width, height } = button.getBoundingClientRect();
|
||||
x = left + width / 2;
|
||||
y = top + height / 2;
|
||||
}
|
||||
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(x, viewportWidth - x),
|
||||
Math.max(y, viewportHeight - y)
|
||||
)
|
||||
const maxRadius = Math.hypot(Math.max(x, viewportWidth - x), Math.max(y, viewportHeight - y));
|
||||
|
||||
const applyTheme = () => {
|
||||
const nextTheme = targetTheme ?? (isDark ? "light" : "dark")
|
||||
if (nextTheme === (isDark ? "dark" : "light")) return
|
||||
setIsDark(nextTheme === "dark")
|
||||
document.documentElement.classList.toggle("dark", nextTheme === "dark")
|
||||
document.documentElement.style.colorScheme = nextTheme
|
||||
onThemeChange?.(nextTheme)
|
||||
}
|
||||
const nextTheme = targetTheme ?? (isDark ? "light" : "dark");
|
||||
if (nextTheme === (isDark ? "dark" : "light")) return;
|
||||
setIsDark(nextTheme === "dark");
|
||||
document.documentElement.classList.toggle("dark", nextTheme === "dark");
|
||||
document.documentElement.style.colorScheme = nextTheme;
|
||||
onThemeChange?.(nextTheme);
|
||||
};
|
||||
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme()
|
||||
return
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
const clipPath = getThemeTransitionClipPaths(shape, x, y, maxRadius, viewportWidth, viewportHeight);
|
||||
|
||||
const root = document.documentElement
|
||||
root.dataset.magicuiThemeVt = "active"
|
||||
root.style.setProperty(
|
||||
"--magicui-theme-toggle-vt-duration",
|
||||
`${duration}ms`
|
||||
)
|
||||
const root = document.documentElement;
|
||||
root.dataset.magicuiThemeVt = "active";
|
||||
root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
|
||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
|
||||
const cleanup = () => {
|
||||
delete root.dataset.magicuiThemeVt
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from")
|
||||
}
|
||||
delete root.dataset.magicuiThemeVt;
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration");
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from");
|
||||
};
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme)
|
||||
})
|
||||
flushSync(applyTheme);
|
||||
});
|
||||
if (typeof transition?.finished?.finally === "function") {
|
||||
transition.finished.finally(cleanup)
|
||||
transition.finished.finally(cleanup);
|
||||
} else {
|
||||
cleanup()
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const ready = transition?.ready
|
||||
const ready = transition?.ready;
|
||||
if (ready && typeof ready.then === "function") {
|
||||
ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
@@ -248,22 +179,16 @@ export const AnimatedThemeToggler = ({
|
||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
||||
fill: "forwards",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
|
||||
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={toggleTheme}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
|
||||
{children ?? (isDark ? <Sun /> : <Moon />)}
|
||||
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,148 +1,121 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
|
||||
const BAND_HALF = 17
|
||||
const SWEEP_START = -BAND_HALF
|
||||
const SWEEP_END = 100 + BAND_HALF
|
||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"];
|
||||
const BAND_HALF = 17;
|
||||
const SWEEP_START = -BAND_HALF;
|
||||
const SWEEP_END = 100 + BAND_HALF;
|
||||
|
||||
const sweepEase = (t: number) =>
|
||||
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
|
||||
const sweepEase = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
||||
|
||||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
||||
const bandStart = pos - BAND_HALF
|
||||
const bandEnd = pos + BAND_HALF
|
||||
const bandStart = pos - BAND_HALF;
|
||||
const bandEnd = pos + BAND_HALF;
|
||||
|
||||
if (bandStart >= 100) {
|
||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`
|
||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`;
|
||||
}
|
||||
const n = colors.length
|
||||
const parts: string[] = []
|
||||
const n = colors.length;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (bandStart > 0)
|
||||
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
|
||||
if (bandStart > 0) parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`);
|
||||
|
||||
colors.forEach((c, i) => {
|
||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
|
||||
parts.push(`${c} ${pct.toFixed(2)}%`)
|
||||
})
|
||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2;
|
||||
parts.push(`${c} ${pct.toFixed(2)}%`);
|
||||
});
|
||||
|
||||
if (bandEnd < 100)
|
||||
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
|
||||
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function measureWidths(el: HTMLElement, texts: string[]) {
|
||||
const ghost = el.cloneNode() as HTMLElement
|
||||
const ghost = el.cloneNode() as HTMLElement;
|
||||
Object.assign(ghost.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
width: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
})
|
||||
el.parentElement!.appendChild(ghost)
|
||||
});
|
||||
el.parentElement!.appendChild(ghost);
|
||||
const widths = texts.map((t) => {
|
||||
ghost.textContent = t
|
||||
return ghost.getBoundingClientRect().width
|
||||
})
|
||||
ghost.remove()
|
||||
return widths
|
||||
ghost.textContent = t;
|
||||
return ghost.getBoundingClientRect().width;
|
||||
});
|
||||
ghost.remove();
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link DiaTextReveal}.
|
||||
*/
|
||||
export interface DiaTextRevealProps extends Omit<
|
||||
HTMLMotionProps<"span">,
|
||||
"ref" | "children" | "style" | "animate" | "transition" | "color"
|
||||
> {
|
||||
export interface DiaTextRevealProps extends Omit<HTMLMotionProps<"span">, "ref" | "children" | "style" | "animate" | "transition" | "color"> {
|
||||
/**
|
||||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
||||
*/
|
||||
text: string | string[]
|
||||
text: string | string[];
|
||||
/**
|
||||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
||||
*/
|
||||
colors?: string[]
|
||||
colors?: string[];
|
||||
/**
|
||||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
||||
* @defaultValue `"var(--foreground)"`
|
||||
*/
|
||||
textColor?: string
|
||||
textColor?: string;
|
||||
/**
|
||||
* Duration of one sweep pass, in seconds.
|
||||
* @defaultValue `1.5`
|
||||
*/
|
||||
duration?: number
|
||||
duration?: number;
|
||||
/**
|
||||
* Delay before the sweep starts, in seconds.
|
||||
* @defaultValue `0`
|
||||
*/
|
||||
delay?: number
|
||||
delay?: number;
|
||||
/**
|
||||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
repeat?: boolean
|
||||
repeat?: boolean;
|
||||
/**
|
||||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
||||
* @defaultValue `0.5`
|
||||
*/
|
||||
repeatDelay?: number
|
||||
repeatDelay?: number;
|
||||
/**
|
||||
* If `true`, the animation starts only after the element enters the viewport.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
startOnView?: boolean
|
||||
startOnView?: boolean;
|
||||
/**
|
||||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
once?: boolean
|
||||
once?: boolean;
|
||||
/**
|
||||
* Additional class names for the animated `span` (e.g. typography utilities).
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
/**
|
||||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
fixedWidth?: boolean;
|
||||
}
|
||||
|
||||
export function DiaTextReveal({
|
||||
text,
|
||||
colors = DEFAULT_COLORS,
|
||||
textColor = "var(--foreground)",
|
||||
duration = 1.5,
|
||||
delay = 0,
|
||||
repeat = false,
|
||||
repeatDelay = 0.5,
|
||||
startOnView = true,
|
||||
once = true,
|
||||
className,
|
||||
fixedWidth = false,
|
||||
...props
|
||||
}: DiaTextRevealProps) {
|
||||
const texts = Array.isArray(text) ? text : [text]
|
||||
const isMulti = texts.length > 1
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
export function DiaTextReveal({ text, colors = DEFAULT_COLORS, textColor = "var(--foreground)", duration = 1.5, delay = 0, repeat = false, repeatDelay = 0.5, startOnView = true, once = true, className, fixedWidth = false, ...props }: DiaTextRevealProps) {
|
||||
const texts = Array.isArray(text) ? text : [text];
|
||||
const isMulti = texts.length > 1;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
const spanRef = useRef<HTMLSpanElement>(null)
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const optsRef = useRef({
|
||||
colors,
|
||||
textColor,
|
||||
@@ -151,7 +124,7 @@ export function DiaTextReveal({
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
})
|
||||
});
|
||||
optsRef.current = {
|
||||
colors,
|
||||
textColor,
|
||||
@@ -160,79 +133,71 @@ export function DiaTextReveal({
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
}
|
||||
};
|
||||
|
||||
const indexRef = useRef(0)
|
||||
const hasPlayedRef = useRef(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const playRef = useRef<() => void>(null!)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
const indexRef = useRef(0);
|
||||
const hasPlayedRef = useRef(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const playRef = useRef<() => void>(null!);
|
||||
const stopRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
const sweepPos = useMotionValue(SWEEP_START);
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = spanRef.current
|
||||
if (!el || !isMulti) return
|
||||
setMeasuredWidths(measureWidths(el, texts))
|
||||
}, [Array.isArray(text) ? text.join("\0") : text])
|
||||
const el = spanRef.current;
|
||||
if (!el || !isMulti) return;
|
||||
setMeasuredWidths(measureWidths(el, texts));
|
||||
}, [Array.isArray(text) ? text.join("\0") : text]);
|
||||
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
sweepPos.set(SWEEP_START);
|
||||
|
||||
const controls = animate(sweepPos, SWEEP_END, {
|
||||
duration,
|
||||
delay,
|
||||
ease: sweepEase,
|
||||
onComplete() {
|
||||
if (!repeat) return
|
||||
if (!repeat) return;
|
||||
timerRef.current = setTimeout(() => {
|
||||
const next = (indexRef.current + 1) % texts.length
|
||||
indexRef.current = next
|
||||
setActiveIndex(next)
|
||||
playRef.current()
|
||||
}, repeatDelay * 1000)
|
||||
const next = (indexRef.current + 1) % texts.length;
|
||||
indexRef.current = next;
|
||||
setActiveIndex(next);
|
||||
playRef.current();
|
||||
}, repeatDelay * 1000);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
stopRef.current = () => controls.stop()
|
||||
}
|
||||
stopRef.current = () => controls.stop();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
sweepPos.set(SWEEP_END)
|
||||
return
|
||||
sweepPos.set(SWEEP_END);
|
||||
return;
|
||||
}
|
||||
if (startOnView && !isInView) return
|
||||
if (once && hasPlayedRef.current) return
|
||||
hasPlayedRef.current = true
|
||||
playRef.current()
|
||||
if (startOnView && !isInView) return;
|
||||
if (once && hasPlayedRef.current) return;
|
||||
hasPlayedRef.current = true;
|
||||
playRef.current();
|
||||
|
||||
return () => {
|
||||
stopRef.current?.()
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
|
||||
stopRef.current?.();
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: undefined
|
||||
const animatedW = isMulti && !fixedWidth && measuredWidths[activeIndex] != null ? measuredWidths[activeIndex] : undefined;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
@@ -259,5 +224,5 @@ export function DiaTextReveal({
|
||||
>
|
||||
{texts[activeIndex]}
|
||||
</motion.span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -48,13 +48,11 @@ export function useVersionCheck() {
|
||||
}
|
||||
}, [currentVersion]);
|
||||
|
||||
const checkLatestRelease = useCallback(async (showMessage = false) => {
|
||||
const checkLatestRelease = useCallback(
|
||||
async (showMessage = false) => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const [versionResponse, changelogResponse] = await Promise.all([
|
||||
fetch(latestVersionUrl),
|
||||
fetch(latestChangelogUrl),
|
||||
]);
|
||||
const [versionResponse, changelogResponse] = await Promise.all([fetch(latestVersionUrl), fetch(latestChangelogUrl)]);
|
||||
if (!versionResponse.ok) throw new Error("版本读取失败");
|
||||
if (!changelogResponse.ok) throw new Error("更新日志读取失败");
|
||||
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
|
||||
@@ -70,7 +68,9 @@ export function useVersionCheck() {
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [currentVersion, localReleases, message]);
|
||||
},
|
||||
[currentVersion, localReleases, message],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void checkLatestVersion();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export async function deleteAdminPrompt(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
|
||||
}
|
||||
|
||||
export async function deleteAdminPrompts(token: string, ids: string[]) {
|
||||
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
|
||||
}
|
||||
|
||||
export type AdminAssetQuery = {
|
||||
keyword?: string;
|
||||
type?: string;
|
||||
@@ -67,11 +71,7 @@ export type AdminAssetQuery = {
|
||||
};
|
||||
|
||||
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
|
||||
return apiGet<AdminAssetListResponse>(
|
||||
"/api/admin/assets",
|
||||
compactApiParams(query),
|
||||
token,
|
||||
);
|
||||
return apiGet<AdminAssetListResponse>("/api/admin/assets", compactApiParams(query), token);
|
||||
}
|
||||
|
||||
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
|
||||
@@ -108,6 +108,10 @@ export type AdminPublicSettings = {
|
||||
|
||||
export type AdminPrivateSettings = {
|
||||
channels: AdminModelChannel[];
|
||||
promptSync: {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminSettings = {
|
||||
|
||||
@@ -56,7 +56,10 @@ function readAxiosError(error: unknown, fallback: string) {
|
||||
function parseStreamChunk(chunk: string, onDelta: (value: string) => void) {
|
||||
let deltaText = "";
|
||||
for (const eventBlock of chunk.split("\n\n")) {
|
||||
const data = eventBlock.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
const data = eventBlock
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice(6);
|
||||
if (!data || data === "[DONE]") continue;
|
||||
const delta = (JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content || "";
|
||||
deltaText += delta;
|
||||
@@ -75,7 +78,9 @@ function aiApiUrl(config: AiConfig, path: string) {
|
||||
|
||||
function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
return config.channelMode === "remote"
|
||||
? contentType ? { "Content-Type": contentType } : undefined
|
||||
? contentType
|
||||
? { "Content-Type": contentType }
|
||||
: undefined
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
|
||||
@@ -23,13 +23,16 @@ export type PromptListResponse = {
|
||||
};
|
||||
|
||||
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
|
||||
return apiGet<PromptListResponse>("/api/prompts", compactApiParams({
|
||||
return apiGet<PromptListResponse>(
|
||||
"/api/prompts",
|
||||
compactApiParams({
|
||||
...(keyword ? { keyword } : {}),
|
||||
...(tag.length ? { tag } : {}),
|
||||
...(category !== ALL_PROMPTS_OPTION ? { category } : {}),
|
||||
...(page ? { page } : {}),
|
||||
...(pageSize ? { pageSize } : {}),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatPromptDate(value: string) {
|
||||
|
||||
@@ -9,9 +9,7 @@ type ApiResponse<T> = {
|
||||
};
|
||||
|
||||
export function compactApiParams(params: ApiParams) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0)),
|
||||
) as ApiParams;
|
||||
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
|
||||
}
|
||||
|
||||
export function serializeApiParams(params?: ApiParams) {
|
||||
|
||||
@@ -39,18 +39,20 @@ export async function resolveImageUrl(storageKey?: string, fallback = "") {
|
||||
}
|
||||
|
||||
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) {
|
||||
const url = image.dataUrl || await resolveImageUrl(image.storageKey, image.url || "");
|
||||
const url = image.dataUrl || (await resolveImageUrl(image.storageKey, image.url || ""));
|
||||
if (!url || url.startsWith("data:")) return url;
|
||||
return blobToDataUrl(await (await fetch(url)).blob());
|
||||
}
|
||||
|
||||
export async function deleteStoredImages(keys: Iterable<string>) {
|
||||
await Promise.all(Array.from(new Set(keys)).map(async (key) => {
|
||||
await Promise.all(
|
||||
Array.from(new Set(keys)).map(async (key) => {
|
||||
const url = objectUrls.get(key);
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
objectUrls.delete(key);
|
||||
await store.removeItem(key);
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanupUnusedImages(usedData: unknown) {
|
||||
@@ -65,7 +67,7 @@ export async function cleanupUnusedImages(usedData: unknown) {
|
||||
export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) {
|
||||
if (!value || typeof value !== "object") return keys;
|
||||
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.startsWith("image:")) keys.add(value.storageKey);
|
||||
Object.values(value).forEach((item) => Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys));
|
||||
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys)));
|
||||
return keys;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,11 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
const value = await localForageStorage.getItem(name);
|
||||
if (!value) return null;
|
||||
const parsed = JSON.parse(value) as StorageValue<AssetStore>;
|
||||
parsed.state.assets = await Promise.all(parsed.state.assets.map(async (asset) => {
|
||||
parsed.state.assets = await Promise.all(
|
||||
parsed.state.assets.map(async (asset) => {
|
||||
if (asset.kind !== "image") return asset;
|
||||
if (asset.data.storageKey) return {
|
||||
if (asset.data.storageKey)
|
||||
return {
|
||||
...asset,
|
||||
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl,
|
||||
data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
|
||||
@@ -50,7 +52,8 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
|
||||
const image = await uploadImage(asset.data.dataUrl);
|
||||
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
return parsed;
|
||||
},
|
||||
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
|
||||
@@ -67,10 +70,12 @@ export const useAssetStore = create<AssetStore>()(
|
||||
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] }));
|
||||
return id;
|
||||
},
|
||||
updateAsset: (id, patch) => set((state) => ({
|
||||
assets: state.assets.map((asset) => asset.id === id ? { ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset : asset),
|
||||
updateAsset: (id, patch) =>
|
||||
set((state) => ({
|
||||
assets: state.assets.map((asset) => (asset.id === id ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
|
||||
})),
|
||||
removeAsset: (id) => set((state) => {
|
||||
removeAsset: (id) =>
|
||||
set((state) => {
|
||||
const assets = state.assets.filter((asset) => asset.id !== id);
|
||||
get().cleanupImages({ assets });
|
||||
return { assets };
|
||||
|
||||
+4
-19
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -23,20 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["next-env.d.ts", "next.config.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user