Compare commits

...

66 Commits

Author SHA1 Message Date
HouYunFei e9fe5b0651 feat(video): 新增火山方舟 Agent Plan 接入 Seedance 2.0 视频生成功能
- 支持通过火山方舟 Agent Plan 接入 Seedance 2.0 视频生成,后端自动适配任务创建、轮询和结果回填接口
- 视频创作台和画布视频生成支持 Seedance 分辨率、比例、时长、生成声音、水印及参考图片、参考视频、参考音频输入
- 画布新增音频节点,并新增参考素材上传与公开访问接口,配合 PUBLIC_BASE_URL 供火山方舟拉取本地参考素材
- 管理后台模型渠道支持 Agent Plan Base URL 归一、Seedance 模型配置提示、渠道探测超时和已启用模型自动同步到公开可用模型
- 图片/视频参考素材按 图片1、视频1、音频1 编号注入提示词,图像请求尺寸和质量参数在请求前归一校验
- 修复 Docker 部署时 SQLite 相对路径可能写入错误目录的问题,并优化 AI 上游鉴权、限流和 Seedance 敏感参考视频错误提示
2026-06-01 11:03:46 +08:00
HouYunFei 3aee6d1eee Merge branch 'fork/stupid-h4er/feat/ark-agentplan-seedance-video'
# Conflicts:
#	docs/pending-test.md
2026-06-01 10:53:16 +08:00
stupid-h4er c3b1ba2943 feat: label image references in generation 2026-05-31 00:56:40 +08:00
HouYunFei c63b843ff1 chore(version): 更新版本号从 v0.1.0 到 v0.1.1
- 将 VERSION 文件中的版本号从 v0.1.0 更新为 v0.1.1
2026-05-30 13:13:13 +08:00
HouYunFei 50a3a94876 chore(version): 更新版本号从 v0.1.0 到 v0.1.1
- 将 VERSION 文件中的版本号从 v0.1.0 更新为 v0.1.1
2026-05-30 13:09:01 +08:00
HouYunFei 6867f3e1e6 feat(config): 支持通过URL参数自动配置API设置
- 新增通过URL查询参数baseUrl/baseurl和apiKey/apikey自动填充配置的功能
- 实现读取参数后从地址栏自动移除相关参数的安全机制
- 添加后台是否允许自定义渠道的检查逻辑
- 集成Ant Design的消息提示和对话框组件用于用户反馈
- 更新文档说明New API自动配置的使用方法和参数格式
- 优化配置初始化流程以处理外部软件跳转场景
2026-05-30 13:08:43 +08:00
haer 78738e060f Merge branch 'basketikun:main' into feat/ark-agentplan-seedance-video 2026-05-29 23:48:13 +08:00
stupid-h4er 923e21c581 fix: normalize gpt image request size 2026-05-28 14:28:53 +08:00
HouYunFei e740da6702 chore(Dockerfile): 将基础镜像从 Bun 切换到 Node.js 并更新启动命令 2026-05-28 10:36:27 +08:00
stupid-h4er 76461e6dba fix: clarify seedance reference video failures 2026-05-27 22:34:46 +08:00
stupid-h4er 6d185a6d93 fix: persist docker sqlite settings path 2026-05-27 22:11:36 +08:00
stupid-h4er 74ea4b81f2 fix: improve seedance reference handling 2026-05-27 21:55:05 +08:00
stupid-h4er 4bffb55695 feat: add seedance media params and model visibility 2026-05-27 13:02:36 +08:00
stupid-h4er 871bedc072 fix: add timeout for admin channel probes 2026-05-27 11:00:20 +08:00
stupid-h4er 3be0df0df6 fix: clarify ark agent plan model discovery 2026-05-27 10:52:59 +08:00
stupid-h4er fa9a3c1920 fix: normalize ark agent plan base url 2026-05-27 10:08:01 +08:00
stupid-h4er 1b557a86d7 fix: skip text test for ark seedance channels 2026-05-27 10:01:15 +08:00
stupid-h4er e37597f432 docs: add ark agentplan setup notes 2026-05-26 23:10:57 +08:00
stupid-h4er 1b2d473d9c feat: add video node preview 2026-05-26 23:10:49 +08:00
stupid-h4er c9fea71821 feat: add ark seedance video generation 2026-05-26 23:10:41 +08:00
stupid-h4er 1493aed81d chore: inspect ai generation flow 2026-05-26 22:19:38 +08:00
HouYunFei fcde508482 chore(version): 更新版本号并完善变更日志
- 更新版本号从 v0.0.9 到 v0.1.0
- 在变更日志中添加 v0.1.0 版本记录
- 记录优化我的画布、我的素材导出功能
- 记录修复画布撤销、配置节点等 bug 问题
2026-05-26 16:05:40 +08:00
HouYunFei 7e5179c54f fix(canvas): 解决画布节点撤销操作后图片丢失问题
- 添加 cleanupCanvasFiles 回调函数用于清理画布资源
- 将 cleanupAssetImages 替换为 cleanupCanvasFiles 以统一资源清理逻辑
- 在节点变更和画布清空时使用新的清理函数
- 修复撤销操作后节点信息恢复但本地图片数据被清理的错误
2026-05-26 15:50:36 +08:00
HouYunFei fda8efb362 fix(canvas): 修复画布视频设置浮层遮挡和交互问题
- 将视频设置浮层改为挂载到页面根层级并使用自建浮层交互
- 避免被节点悬浮工具栏遮挡或点击面板内容时意外关闭
- 在画布配置节点组件中为视频设置浮层添加 placement 属性
- 更新文档描述以反映浮层交互方式的改进
2026-05-26 15:28:35 +08:00
HouYunFei 7ac1fea662 refactor(canvas): 重构画布图像设置弹窗组件
- 使用 Portal 重新实现弹窗渲染机制
- 移除 Ant Design Popover 组件依赖
- 添加手动定位逻辑替代自动溢出调整
- 优化外部点击关闭事件处理机制
- 修复弹窗位置计算和滚动同步问题
- 简化组件参数并改进 DOM 结构
2026-05-26 15:17:29 +08:00
HouYunFei 005c396660 feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
2026-05-26 14:44:06 +08:00
HouYunFei bf66a25936 feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
2026-05-26 14:09:10 +08:00
HouYunFei de25ef515e feat(assets): 使用 file-saver 库优化文件下载功能
- 引入 file-saver 库替代手动创建下载链接的方式
- 在 asset-transfer.ts 中使用 saveAs 替代 URL.createObjectURL 和手动点击链接
- 在 canvas-export.ts 中使用 saveAs 替代原有的文件下载逻辑
- 更新 package.json 添加 file-saver 依赖
- 更新 bun.lock 文件同步依赖变更
2026-05-26 13:21:52 +08:00
HouYunFei 9311b8a92b feat(asset): 添加素材导入导出功能
- 实现了素材批量导出为包含 assets.json 和媒体文件的压缩包
- 实现了从压缩包导入素材到本地存储的功能
- 在素材页面添加了导出素材和导入素材的按钮入口
- 创建了 asset-transfer 模块处理素材打包和解包逻辑
- 支持图片和视频文件的存储路径映射和恢复
- 添加了文件安全命名和 MIME 类型识别功能
2026-05-26 13:13:05 +08:00
HouYunFei 1fb782819b feat(canvas): 实现画布项目导出为ZIP压缩包功能
- 新增 canvas-export 工具模块,支持将画布项目打包为 ZIP 文件
- 修改导出功能,将原来的 JSON 格式改为 ZIP 压缩包,包含 projects.json 和相关媒体文件
- 添加对 fflate 库的依赖用于 ZIP 文件处理
- 更新画布库页面,支持多选项目批量导出
- 修改导入功能以支持读取新版 ZIP 格式的画布项目
- 新增 zip 处理工具库,封装压缩和解压操作
- 扩展文件存储服务,添加获取和设置 Blob 对象的方法
- 更新 AGENTS.md 文档,添加标准库使用原则
2026-05-26 13:00:16 +08:00
HouYunFei 567ead89b1 feat(canvas): 新增视频创作台页面并优化未登录状态的用户界面显示
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 未登录状态下画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口
- 未登录状态下改为显示登录入口和快捷键按钮
- 用户状态组件中用户名默认值从"用户"改为空字符串
- 根据用户登录状态条件渲染不同的界面元素
2026-05-26 11:35:00 +08:00
HouYunFei 9754af3bf9 fix(image): 修复图片生成和编辑请求中尺寸参数传递问题
- 添加 resolveRequestSize 函数处理尺寸参数解析逻辑
- 将 pixelSize 变量重命名为 requestSize 以提高代码可读性
- 修复当尺寸为 auto 时不发送 size 参数的功能
- 确保所有非 auto 尺寸值都会正确传递到请求中
- 更新图片生成和编辑函数中的尺寸参数处理逻辑
2026-05-26 11:26:27 +08:00
HouYunFei 778fd065ec fix(image): 修复图片生成和编辑请求中尺寸参数传递问题
- 添加 resolveRequestSize 函数处理尺寸参数解析逻辑
- 将 pixelSize 变量重命名为 requestSize 以提高代码可读性
- 修复当尺寸为 auto 时不发送 size 参数的功能
- 确保所有非 auto 尺寸值都会正确传递到请求中
- 更新图片生成和编辑函数中的尺寸参数处理逻辑
2026-05-26 10:19:41 +08:00
HouYunFei f9e4c92ff1 refactor(video): 重构视频配置参数标准化逻辑
- 将视频配置参数标准化逻辑提取为独立的 buildVideoConfig 函数
- 添加视频秒数、尺寸、清晰度的标准化验证函数
- 限制视频秒数只允许 6/10/12/16/20 秒的预设值
- 验证视频尺寸格式是否为标准的宽高比格式
- 优化视频生成 API 调用的错误处理机制
- 统一错误消息显示,修复页面残留生图参数影响视频请求的问题
2026-05-25 17:49:12 +08:00
HouYunFei 1be6b4fa29 feat(video): 新增视频创作台页面
- 添加 /video 页面路由及完整视频生成界面
- 集成视频生成 API 调用及本地存储功能
- 实现提示词、参考图、视频参数设置界面
- 添加生成结果预览、下载和保存素材功能
- 支持本地生成记录管理及历史查看
- 在导航菜单中添加视频创作台入口
- 更新 CHANGELOG 包含新功能说明
2026-05-25 17:37:43 +08:00
HouYunFei 796882064b feat(account): 新增用户账号与算力点体系
- 新增账号密码注册登录功能
- 集成 Linux.do OAuth 认证
- 添加管理后台配置模型算力点功能
- 实现计费查询支持
- 在画布右上角显示用户算力点余额
- 生成按钮展示本次预计消耗算力点
2026-05-25 17:20:03 +08:00
HouYunFei 8c506f92f9 feat(video): 对齐 grok-imagine-video 接口并优化视频生成功能
- 将默认视频模型从 sora-2 更改为 grok-imagine-video
- 新增分辨率选项包括 720p 和 480p,替换原有的质量选项
- 扩展视频尺寸选项,新增方形、宽屏、长图等预设尺寸
- 添加分辨率和尺寸手动输入功能,支持自定义宽高
- 更新视频生成 API 参数为 resolution_name、preset 和 input_reference[]
- 支持最多 7 张参考图片上传
- 优化视频设置浮层定位,避免被工具栏遮挡
- 识别后端错误响应格式,改进视频生成失败处理逻辑
2026-05-25 17:15:42 +08:00
HouYunFei b21f8c3af9 feat(admin): 优化管理后台渠道模型选择功能
- 在管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按"新获取、已有"分组选择
- 弹窗内支持手动增加模型或拉取模型列表后再写回表单
- 编辑渠道时API Key留空不再触发必填校验,表示沿用已保存的密钥
- 新增渠道仍要求填写API Key以确保安全性
- 公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成
- 最终开放哪些模型仍由公开配置里手动勾选决定
- 添加了Checkbox组件用于多选模型功能
- 实现了模型选择器的状态管理和数据过滤逻辑
2026-05-25 16:37:18 +08:00
HouYunFei d8cb1d6ed2 fix(image): 修复图像生成中quality参数传递问题
- 添加QUALITY_ALIASES映射表支持质量别名
- 实现normalizeQuality函数进行参数归一化
- 在requestGeneration和requestEdit中验证quality参数
- 防止auto或异常值传递给上游API导致400错误
- 更新FormData和POST请求中的quality参数处理逻辑
2026-05-25 16:15:10 +08:00
HouYunFei b8e50c10f6 feat(auth): 添加用户注册开关功能
- 在系统设置中新增 allowRegister 配置项控制用户注册
- 实现注册接口的开关检查机制
- 登录页面根据注册开关动态显示或隐藏注册选项
- 首次 Linux.do 登录创建用户时也受注册开关限制
- 更新系统配置文档说明新的认证配置项
2026-05-25 16:03:54 +08:00
HouYunFei 030541b99d feat(canvas): 添加画布图片信息显示功能
- 在画布项目状态中添加 showImageInfo 属性
- 实现图片信息栏组件显示图片尺寸和文件大小
- 添加画布工具栏开关控制图片信息显示
- 将图片信息状态集成到项目历史记录和持久化
- 更新画布节点组件支持图片信息显示功能
2026-05-25 15:51:14 +08:00
HouYunFei 2934b1d0bc refactor(ai): 重构AI接口算力点扣费逻辑并添加失败返还机制
- 移除预检查算力点步骤,改为请求前预扣算力点
- 添加请求失败时自动返还算力点的功能
- 修改copyAIResponse函数参数以支持失败回调处理
- 在响应状态异常时也执行算力点返还操作
- 从auth服务中移除EnsureUserCredits方法
- 新增RefundUserCredits方法用于返还用户算力点
- 添加AI_REFUND算力点日志类型常量
- 更新算力点日志类型说明及展示标签
2026-05-25 15:23:51 +08:00
HouYunFei 01f2a4d9d5 feat(ai): 实现AI模型调用的积分计费功能
- 后端根据请求体中的n参数计算AI调用次数并扣减用户积分
- 添加readAIRequestCount函数解析multipart/form-data和JSON格式的请求体获取调用次数
- 前端Canvas组件中显示预计消耗的积分数量
- 在助手面板、配置节点面板和提示面板中集成积分成本计算和展示
- 优化模型选择器在云端模式下的模型列表显示逻辑
- 添加CreditSymbol组件用于统一积分图标显示
- 实现requestCreditCost函数计算远程调用的积分消耗
- 更新文档中关于积分扣费和模型配置的相关说明
2026-05-25 15:01:06 +08:00
HouYunFei 823fb2523f feat(canvas): 在画布用户状态栏新增算力点余额展示
- 在画布详情页右上角用户头像左侧新增当前算力点余额展示
- 未登录或无用户信息时显示 0
- 使用 Coins 图标配合数字显示算力点余额
- 通过 Tooltip 提供当前算力点余额提示信息
- 算力点数值采用千分位分隔格式化显示
- 仅在画布模式下显示算力点余额信息
2026-05-25 14:25:57 +08:00
HouYunFei 2181b3b885 feat(admin): 添加模型算力点配置和扣费功能
- 在管理后台设置页面添加模型算力点配置表格
- 实现后端AI接口调用时按模型扣除用户算力点
- 添加算力点余额检查和不足时的错误处理
- 在credit_logs中记录AI调用消费流水
- 更新前端远程调用后刷新用户信息显示
- 文档中补充模型算力点配置说明
2026-05-25 14:01:04 +08:00
HouYunFei 392bd49d8c Merge branch 'fork/maoqingheng/feature/xiaoxuan' 2026-05-25 12:58:13 +08:00
HouYunFei fff7074218 feat(admin): 添加管理员用户算力点管理和日志功能
- 在管理员API中新增用户算力点调整功能和信用日志相关接口
- 实现后端用户算力点调整逻辑和信用日志的增删改查操作
- 在管理员界面用户管理页面添加算力点调整功能
- 新增独立的算力点日志管理页面,支持日志查看和编辑
- 添加算力点日志相关的数据模型和服务函数
- 更新数据库文档说明第三方资料存储结构
- 添加dayjs依赖用于时间格式化显示
2026-05-25 12:57:27 +08:00
HouYunFei fe3294ed60 feat(auth): 添加用户认证中间件并重构Linux.do登录流程
- 新增UserAuth中间件用于验证用户登录状态和权限
- 移除Linux.do登录中的redirectUri和minimumTrustLevel配置项
- 修改Linux.do登录流程以支持动态构建回调URL
- 将API v1接口组迁移至需要用户认证的路由
- 在远程AI服务调用中添加用户令牌认证
- 修复被封禁用户的认证检查逻辑
- 更新登录重定向函数以处理请求来源
- 添加RequestOrigin函数解析转发的主机和协议头
2026-05-25 12:05:02 +08:00
hengmaoqing 51ea17e8d9 feat(image): 实现图像生成质量与尺寸自适应功能
- 新增 QUALITY_BASE 配置对象定义低中高质量的基础像素值
- 实现 resolveSize 函数将质量等级与宽高比转换为精确像素尺寸
- 在图像生成请求中集成像素尺寸解析逻辑
- 更新图像编辑功能以支持动态像素尺寸设置
- 优化 API 请求参数传递方式使用展开运算符
- 调整页面组件中的质量选项显示标签增加像素信息
2026-05-25 11:53:39 +08:00
HouYunFei b2cae2471d feat(admin): 添加用户管理和Linux.do登录功能
- 新增AdminUser类型定义及用户管理API接口
- 实现用户列表查询、保存和删除功能
- 添加Linux.do第三方登录支持
- 集成Linux.do OAuth认证流程
- 在系统设置中添加Linux.do登录配置选项
- 实现算力点余额调整记录功能
- 优化搜索组件关键词状态管理
- 更新数据库迁移添加credit_logs表
- 完善用户状态和信用等级验证逻辑
2026-05-25 11:21:09 +08:00
HouYunFei f871a5d015 feat(canvas): 优化图片和视频节点尺寸适配
- 在 InsertAssetPayload 类型中为视频添加 width 和 height 属性
- 引入 fitNodeSize 和 nodeSizeFromRatio 工具函数处理节点尺寸计算
- 设置视频节点最大宽度和高度限制为 420px
- 将原有的 fitImageNodeSize 替换为通用的 fitNodeSize 函数
- 为视频节点添加自然宽高元数据存储
- 实现视频文件上传时读取真实宽高信息
- 支持空节点在尺寸比例切换时自动调整外框大小
- 修复视频节点在生成和重试过程中的尺寸适配问题
- 统一图片和视频节点的尺寸调整逻辑
- 添加视频节点纵横比锁定功能
2026-05-23 18:30:32 +08:00
HouYunFei ef7772a703 feat(canvas): 添加视频模型配置和生成功能
- 在系统配置中新增 defaultVideoModel 字段用于设置默认视频模型
- 更新应用配置模态框以支持视频模型的选择和验证
- 修改画布节点逻辑以支持视频类型的生成配置
- 实现视频节点的生成和上传功能
- 更新模型选择器组件以支持视频模型选择
- 在管理后台设置页面添加视频模型配置选项
- 优化模型选择器的层级和交互体验
- 添加对 DeepSeek 和 GLM 模型图标的支持
2026-05-23 18:02:05 +08:00
HouYunFei 370263cd83 feat(canvas): 添加视频模型配置和生成功能
- 在系统配置中新增 defaultVideoModel 字段用于设置默认视频模型
- 更新应用配置模态框以支持视频模型的选择和验证
- 修改画布节点逻辑以支持视频类型的生成配置
- 实现视频节点的生成和上传功能
- 更新模型选择器组件以支持视频模型选择
- 在管理后台设置页面添加视频模型配置选项
- 优化模型选择器的层级和交互体验
- 添加对 DeepSeek 和 GLM 模型图标的支持
2026-05-23 17:25:00 +08:00
HouYunFei 44c5825cb0 feat(canvas): 添加视频节点支持功能
- 在后端添加 AI 视频相关接口处理函数
- 扩展前端画布组件支持视频节点类型
- 实现视频上传、生成和播放功能
- 更新画布数据结构文档以包含视频节点
- 添加视频节点的操作工具栏和配置面板
- 集成视频存储和检索服务
- 优化视频节点的渲染和交互体验
2026-05-23 16:53:21 +08:00
HouYunFei 0cc27b5a4c chore(version): 更新版本号到 v0.0.7 2026-05-23 16:19:23 +08:00
HouYunFei 83031e0864 style(canvas): 调整画布组件样式和交互行为
- 更新CSS选择器以正确隐藏和显示画布工具栏元素
- 为模型选择器下拉菜单添加data-canvas-no-zoom属性防止缩放冲突
- 在模型选择器中阻止指针和鼠标事件传播避免意外操作
- 为模型图标添加深色模式反转效果提升视觉一致性
2026-05-23 16:18:14 +08:00
HouYunFei 593feddc3f refactor(canvas): 优化模型选择器和定时同步配置
- 将模型选择器替换为统一的 shadcn 风格下拉组件
- 移除原生数字输入框的步进箭头样式
- 更新默认定时同步配置为每5分钟执行
- 修复画布助手和节点配置中的模型选择逻辑
- 统一各页面中的模型选择器样式和交互行为
2026-05-23 15:56:17 +08:00
HouYunFei c9682481d7 Merge remote-tracking branch 'origin/main' 2026-05-23 15:43:33 +08:00
kunkun 2e4c0d0f0a Merge pull request #8 from 1847123733/main
feat(canvas): 添加图片预览功能
2026-05-23 15:43:22 +08:00
王鑫凯 697e1fc1eb feat(canvas): 添加图片预览功能
- 新增 previewNodeId 状态用于管理图片预览弹窗
- 在 CanvasNodeHoverToolbar 中添加查看大图按钮
- 实现图片详情模态框组件
- 添加图片预览功能的回调处理逻辑
2026-05-23 15:32:44 +08:00
HouYunFei ca4e87bddf refactor(canvas): 重构画布助手面板组件结构
- 移除 Segmented 组件并替换为自定义模式切换按钮
- 替换 ModelPicker 为 ComposerModelPill 组件
- 添加下拉菜单实现模型选择功能
- 优化 CSS 样式类和响应式布局
- 更新图标引入和工具栏组件结构
- 简化提示词库按钮样式配置
2026-05-23 15:08:13 +08:00
HouYunFei e319481976 style(code): 格式化代码缩进和布局样式
- 统一调整 admin.ts 文件中的接口定义缩进格式
- 优化 animated-theme-toggler.tsx 组件的代码结构和缩进
- 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式
- 整理 app-providers.tsx 中的组件层级缩进
- 标准化 app-theme.ts 中的对象属性缩进格式
2026-05-22 23:19:25 +08:00
HouYunFei cee08cb5a8 feat(canvas): 优化模型选择器和画布助手设置面板
- 模型选择器添加模糊匹配和图标显示支持OpenAI、Claude、Gemini
- 模型选择器下拉滚动条改为更窄的轻量样式
- 画布助手生图设置弹层调整为紧凑图像设置面板避免裁切问题
- 画布节点设置面板打开时隐藏悬浮工具条避免遮挡
2026-05-22 22:14:30 +08:00
HouYunFei 133861590e refactor(canvas): 重构画布图像设置组件为独立面板
- 将图像设置弹层提取为独立的 CanvasImageSettingsPopover 组件
- 在节点对话框中集成新的紧凑图像设置面板
- 移除旧的 ImageSettingsPopover 内联实现
- 更新画布助手面板以使用新组件
- 修复节点工具栏在图像设置打开时的显示逻辑
- 调整节点配置中的质量属性继承顺序
2026-05-22 17:49:55 +08:00
HouYunFei 6049ad2df3 feat(admin): 添加提示词批量删除和定时同步功能
- 后端新增批量删除提示词接口和实现
- 前端管理页面添加多选和批量删除功能
- 添加定时同步GitHub远程提示词源功能
- 新增系统设置中的提示词同步配置选项
- 更新文档说明新的批量操作和定时同步功能
- 添加相关的测试用例和依赖库
2026-05-22 16:11:55 +08:00
HouYunFei 03396a9d18 feat(admin): 添加提示词批量删除和定时同步功能
- 后端新增批量删除提示词接口和实现
- 前端管理页面添加多选和批量删除功能
- 添加定时同步GitHub远程提示词源功能
- 新增系统设置中的提示词同步配置选项
- 更新文档说明新的批量操作和定时同步功能
- 添加相关的测试用例和依赖库
2026-05-22 16:01:00 +08:00
157 changed files with 17889 additions and 10598 deletions
+5
View File
@@ -9,12 +9,17 @@ JWT_EXPIRE_HOURS=168
# 后端监听端口
PORT=8080
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
# PUBLIC_BASE_URL=https://your-domain.example.com
# 前端开发代理默认使用 http://127.0.0.1:8080,如后端开发端口不同,启动前端时可单独设置 API_BASE_URL。
# API_BASE_URL=http://127.0.0.1:8080
# 数据库配置,默认使用本地 SQLite。
STORAGE_DRIVER=sqlite
# sqlite: DATABASE_DSN=data/infinite-canvas.db
# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
DATABASE_DSN=data/infinite-canvas.db
+2
View File
@@ -6,6 +6,7 @@
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
@@ -59,6 +60,7 @@
- 优先使用 `canvasThemes``useThemeStore` 或 Ant Design `ConfigProvider` token。
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
+39
View File
@@ -2,6 +2,45 @@
## Unreleased
## v0.2.0 - 2026-06-01
+ [新增] 支持通过火山方舟 Agent Plan 接入 Seedance 2.0 视频生成,后端会自动适配任务创建、轮询和结果回填接口。
+ [新增] 视频创作台和画布视频生成支持 Seedance 分辨率、比例、时长、生成声音、水印,以及参考图片、参考视频、参考音频输入。
+ [新增] 画布新增音频节点,并新增参考素材上传与公开访问接口,配合 `PUBLIC_BASE_URL` 供火山方舟拉取本地参考素材。
+ [优化] 管理后台模型渠道支持 Agent Plan Base URL 归一、Seedance 模型配置提示、渠道探测超时和已启用模型自动同步到公开可用模型。
+ [优化] 图片/视频参考素材会按 `图片1``视频1``音频1` 编号注入提示词,图像请求尺寸和质量参数也会在请求前归一校验。
+ [修复] 修复 Docker 部署时 SQLite 相对路径可能写入错误目录的问题,并优化 AI 上游鉴权、限流和 Seedance 敏感参考视频错误提示。
## v0.1.1 - 2026-05-30
+ [新增] 支持New API跳转并自动填入Base URL和API Key配置。
## v0.1.0 - 2026-05-26
+ [优化] 优化我的画布、我的素材导出功能
+ [修复] 修复画布撤销,配置节点等bug问题
## v0.0.9 - 2026-05-26
+ [新增] 新增视频创作台页面。
+ [修复] 修复图片节点size参数传递问题。
## v0.0.8 - 2026-05-24
+ [新增] 新增用户账号与算力点体系,支持账号密码注册登录、Linux.do OAuth。
+ [新增] 管理后台公开配置支持设置模型算力点、支持计费查询。
+ [新增] 画布右上角展示用户算力点余额,生成按钮会展示本次预计消耗算力点。
+ [新增] 新增视频生成节点。
## v0.0.7 - 2026-05-23
+ [新增] 管理后台提示词管理支持多选批量删除。
+ [新增] 新增定义拉取GitHub提示词源功能。
+ [新增] 新增awesome-gpt-image2-prompts提示词来源。
+ [优化] 优化模型下拉选择样式、优化生图编辑设置
## v0.0.6 - 2026-05-22
+ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
+2 -2
View File
@@ -25,7 +25,7 @@ COPY main.go ./
RUN go build -o /server .
# 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。
FROM oven/bun:1.3.13
FROM node:22-bookworm-slim
WORKDIR /app
COPY VERSION /app/VERSION
@@ -38,4 +38,4 @@ RUN mkdir -p /app/data/prompts
EXPOSE 3000
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && HOSTNAME=0.0.0.0 PORT=3000 bun run start"]
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && HOSTNAME=0.0.0.0 PORT=3000 npm run start"]
+12 -2
View File
@@ -14,7 +14,7 @@
## 核心功能
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑文本问答。
- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑文本问答和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
@@ -51,6 +51,17 @@ docker compose -f docker-compose.local.yml up -d --build
如需要拉取提示词,可前往:`http://localhost:3000/admin/prompts`
## New API 自动配置
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
```text
https://infinite-canvas-cpco.onrender.com?apiKey={key}&baseUrl={address}
```
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
如果自己部署了,可以把 `https://infinite-canvas-cpco.onrender.com` 替换成你部署的地址。
## 效果展示
<table width="100%">
@@ -89,7 +100,6 @@ docker compose -f docker-compose.local.yml up -d --build
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
## Star History
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
+1 -1
View File
@@ -1 +1 @@
v0.0.6
v0.2.0
+41 -7
View File
@@ -3,6 +3,8 @@ package config
import (
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"strings"
"github.com/caarlos0/env/v11"
@@ -10,13 +12,17 @@ import (
)
type Config struct {
Port string `env:"PORT" envDefault:"8080"`
AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
Port string `env:"PORT" envDefault:"8080"`
AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
PublicBaseURL string `env:"PUBLIC_BASE_URL"`
LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"`
LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"`
LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"`
}
var Cfg Config
@@ -26,6 +32,7 @@ func Load() error {
if err := env.Parse(&Cfg); err != nil {
return err
}
normalizeDockerSQLiteDSN("/app/data")
if strings.TrimSpace(Cfg.JWTSecret) == "" || Cfg.JWTSecret == "infinite-canvas" {
secret, err := randomSecret()
if err != nil {
@@ -36,6 +43,33 @@ func Load() error {
return nil
}
func normalizeDockerSQLiteDSN(appDataDir string) {
driver := strings.ToLower(strings.TrimSpace(Cfg.StorageDriver))
if driver != "" && driver != "sqlite" {
return
}
dsn := strings.TrimSpace(Cfg.DatabaseDSN)
if dsn == "" || dsn == ":memory:" || strings.HasPrefix(dsn, "file:") {
return
}
pathPart, suffix := dsn, ""
if index := strings.Index(dsn, "?"); index >= 0 {
pathPart = dsn[:index]
suffix = dsn[index:]
}
if filepath.IsAbs(pathPart) {
return
}
slashPath := filepath.ToSlash(pathPart)
if slashPath != "data" && !strings.HasPrefix(slashPath, "data/") {
return
}
if _, err := os.Stat(appDataDir); err != nil {
return
}
Cfg.DatabaseDSN = filepath.Join(filepath.Dir(appDataDir), filepath.FromSlash(slashPath)) + suffix
}
func randomSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
+33
View File
@@ -0,0 +1,33 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNormalizeDockerSQLiteDSNUsesMountedDataDir(t *testing.T) {
root := t.TempDir()
appDataDir := filepath.Join(root, "data")
if err := os.MkdirAll(appDataDir, 0755); err != nil {
t.Fatal(err)
}
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db?_pragma=busy_timeout(5000)"}
normalizeDockerSQLiteDSN(appDataDir)
want := filepath.Join(root, "data", "infinite-canvas.db") + "?_pragma=busy_timeout(5000)"
if Cfg.DatabaseDSN != want {
t.Fatalf("DatabaseDSN = %q, want %q", Cfg.DatabaseDSN, want)
}
}
func TestNormalizeDockerSQLiteDSNLeavesLocalPathWithoutMountedDataDir(t *testing.T) {
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db"}
normalizeDockerSQLiteDSN(filepath.Join(t.TempDir(), "missing-data"))
if Cfg.DatabaseDSN != "data/infinite-canvas.db" {
t.Fatalf("DatabaseDSN = %q, want relative local path", Cfg.DatabaseDSN)
}
}
+58 -138
View File
@@ -1,6 +1,6 @@
# 后端数据库说明
本文档记录后端当前已经使用,以及后续规划会用到的主要数据表。
本文档记录后端当前已经使用的主要数据表。
## 数据库
@@ -15,15 +15,16 @@
当前启动时执行 `AutoMigrate`,自动维护以下表:
- `users`
- `credit_logs`
- `prompts`
- `assets`
- `settings`
后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表
后续新增表时再同步补充本文档,未实际使用的规划表不提前写入
### users
系统用户表。用户基础信息、角色、算力点余额和邀请关系放在该表中。
系统用户表。用户基础信息、角色、算力点余额和第三方登录标识放在该表中。
| 字段 | 类型 | 说明 |
|-----------------|--------|--------------------------|
@@ -34,22 +35,22 @@
| `display_name` | string | 昵称 |
| `avatar_url` | string | 头像地址 |
| `role` | string | 角色:`user``admin` |
| `credits` | number | 算力点余额,规划字段 |
| `aff_code` | string | 用户自己的邀请码,唯一索引,规划字段 |
| `aff_count` | number | 已邀请用户数量,冗余统计字段,规划字段 |
| `inviter_id` | string | 邀请人用户 ID,规划字段 |
| `github_id` | string | GitHub 用户 ID,规划字段 |
| `linux_do_id` | string | Linux.do 用户 ID,规划字段 |
| `wechat_id` | string | 微信用户 ID,规划字段 |
| `status` | string | 用户状态:`active``ban`,规划字段 |
| `credits` | number | 算力点余额 |
| `aff_code` | string | 用户自己的邀请码,唯一索引 |
| `aff_count` | number | 已邀请用户数量,冗余统计字段 |
| `inviter_id` | string | 邀请人用户 ID |
| `github_id` | string | GitHub 用户 ID |
| `linux_do_id` | string | Linux.do 用户 ID |
| `wechat_id` | string | 微信用户 ID |
| `status` | string | 用户状态:`active``ban` |
| `last_login_at` | string | 最近登录时间 |
| `extra` | json | 扩展信息 |
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
### prompts
提示词表。后续公开提示词、内置 GitHub 系统提示词、分类和扩展信息都优先放在该表字段或 JSON 中
提示词表。用于保存公开提示词、内置 GitHub 系统提示词、分类和预览内容
| 字段 | 类型 | 说明 |
|--------------|--------|------------------------------|
@@ -59,9 +60,7 @@
| `prompt` | string | 提示词内容 |
| `tags` | json | 标签列表 |
| `category` | string | 分类标识 |
| `visibility` | string | 可见性:公开、私有、系统内置等,规划字段 |
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
@@ -69,25 +68,19 @@
### assets
素材表。当前用于素材库;后续通过 `user_id``visibility``type` 区分系统公开素材和用户私有素材
素材表。当前用于后台素材库。
| 字段 | 类型 | 说明 |
|------------------|--------|-------------------------------|
| `id` | string | 主键 |
| `user_id` | string | 所属用户,为空或系统用户表示公开素材,规划字段 |
| `title` | string | 标题 |
| `type` | string | 素材类型:`text``image``video` 等 |
| `visibility` | string | 可见性:公开、私有,规划字段 |
| `cover_url` | string | 封面图 |
| `tags` | json | 标签列表 |
| `category` | string | 分类标识 |
| `description` | string | 描述 |
| `content` | text | 文本或 Markdown 内容 |
| `url` | string | 图片、视频等媒体地址 |
| `like_count` | number | 点赞量,规划字段 |
| `favorite_count` | number | 收藏量,规划字段 |
| `view_count` | number | 查看量,规划字段 |
| `extra` | json | 扩展信息,规划字段 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
@@ -102,8 +95,8 @@
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。
`private.value` 常放渠道密钥、支付配置、奖励规则、后台内部开关等。
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、登录开关等。
`private.value` 常放渠道密钥、登录密钥、后台内部开关等。
当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。
@@ -112,23 +105,41 @@
| 字段 | 类型 | 说明 |
|-------------------|----------|----------------|
| `modelChannel` | object | 模型渠道公开配置组 |
| `auth` | object | 公开登录配置 |
`modelChannel` 当前字段:
| 字段 | 类型 | 说明 |
|-------------------|----------|----------------|
| `availableModels` | string[] | 系统可用模型列表 |
| `modelCosts` | object[] | 模型算力点配置 |
| `defaultModel` | string | 默认模型 |
| `defaultImageModel` | string | 默认图片模型 |
| `defaultVideoModel` | string | 默认视频模型 |
| `defaultTextModel` | string | 默认文本模型 |
| `systemPrompt` | string | 系统提示词 |
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 |
`modelCosts` 每项字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `model` | string | 模型名称 |
| `credits` | number | 每次后端模型接口调用前预扣的算力点,未配置默认不扣除 |
`auth.linuxDo` 当前字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否开启 Linux.do 登录 |
`private.value` 当前字段:
| 字段 | 类型 | 说明 |
|------------|----------|----------|
| `channels` | object[] | 模型渠道配置列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
| `auth` | object | 私有登录配置 |
`channels` 每项字段:
@@ -143,133 +154,42 @@
| `enabled` | bool | 是否启用 |
| `remark` | string | 备注 |
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否开启定时同步,默认开启 |
| `cron` | string | Cron 表达式,默认每 5 分钟 |
`auth.linuxDo` 当前字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `clientId` | string | Linux.do OAuth App Client ID |
| `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 |
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
### dicts
字典表。一个字典一行,具体字典项数据放在 `items`
| 字段 | 类型 | 说明 |
|--------------|--------|-------|
| `code` | string | 字典编码 |
| `name` | string | 字典名称 |
| `remark` | string | 备注 |
| `items` | text | 字典值数据 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
可维护分类、标签、业务枚举、模型分类、日志类型等。
### credit_logs
用户算力点变更流水表。充值、消费、订阅扣减、邀请奖励、后台调整等余额变化都写入该表
用户算力点变更流水表。当前记录后台手动调整、模型调用预扣和模型调用失败返还
| 字段 | 类型 | 说明 |
|--------------|--------|--------------------------|
| `id` | string | 主键 |
| `user_id` | string | 关联用户 ID |
| `type` | string | 类型:充值、消费、订阅扣减、邀请奖励、后台调整等 |
| `type` | string | 类型:`admin_adjust``ai_consume``ai_refund` |
| `amount` | number | 本次变动数量,增加为正,扣减为负 |
| `balance` | number | 变动后的用户算力点余额 |
| `related_id` | string | 关联订单、任务或日志 ID,可为空 |
| `related_id` | string | 关联业务 ID,可为空 |
| `remark` | string | 备注 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
### orders
`type` 当前取值:
订单表。统一记录充值、订阅购买等支付订单。
| 字段 | 类型 | 说明 |
|---------------------|--------|----------------------|
| `id` | string | 主键 |
| `user_id` | string | 关联用户 ID |
| `type` | string | 订单类型:充值、订阅等 |
| `provider` | string | 支付渠道:Linux LDC、聚合支付等 |
| `amount` | number | 支付金额 |
| `credits` | number | 到账算力点 |
| `status` | string | 订单状态:待支付、已支付、失败、关闭等 |
| `provider_order_id` | string | 第三方订单号 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `paid_at` | string | 支付时间 |
| `updated_at` | string | 更新时间 |
### subscriptions
用户订阅表。一个用户可以有多个订阅记录,套餐配置放在 `settings.public.value` 中。
| 字段 | 类型 | 说明 |
|-----------------|--------|------------------------------------------|
| `id` | string | 主键 |
| `user_id` | string | 关联用户 ID |
| `plan_key` | string | 套餐标识,对应 `settings.public.value` 中的订阅套餐配置 |
| `order_id` | string | 关联订单 ID,可为空 |
| `status` | string | 状态:生效中、已过期、已取消等 |
| `total_credits` | number | 订阅总额度 |
| `used_credits` | number | 已使用额度 |
| `started_at` | string | 开始时间 |
| `expired_at` | string | 过期时间 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
### files
文件表。用于统一管理上传图片、视频等文件,保存最终可访问地址。缩略图和视频封面优先按 URL 命名规则推导,特殊情况放在
`extra.coverUrl`
| 字段 | 类型 | 说明 |
|--------------|--------|-------------|
| `id` | string | 主键 |
| `user_id` | string | 上传用户 ID,可为空 |
| `name` | string | 原始文件名 |
| `url` | string | 完整可访问地址 |
| `mime_type` | string | MIME 类型 |
| `size` | number | 文件大小 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
### canvases
画布表。保存用户私有画布、公开画布和模板,分享、协作、审核等低频配置放在 `extra`
| 字段 | 类型 | 说明 |
|------------------|--------|---------------------------------------------|
| `id` | string | 主键 |
| `user_id` | string | 所属用户 ID |
| `title` | string | 画布标题 |
| `description` | string | 描述 |
| `cover_url` | string | 封面图 |
| `data` | json | 画布节点、边、视图等数据 |
| `visibility` | string | 可见性:`private``public` |
| `status` | string | 状态:`draft``pending``published``rejected` |
| `is_template` | bool | 是否模板 |
| `view_count` | number | 查看量 |
| `like_count` | number | 点赞量 |
| `favorite_count` | number | 收藏量 |
| `copy_count` | number | 复制量 |
| `extra` | json | 扩展信息,如分享、协作、审核备注等 |
| `created_at` | string | 创建时间 |
| `updated_at` | string | 更新时间 |
### generation_tasks
接口调用队列表。用于图片、文本、图生图等后端模型调用的排队、状态和结果记录。
| 字段 | 类型 | 说明 |
|---------------|--------|----------------------|
| `id` | string | 主键 |
| `user_id` | string | 发起用户 ID |
| `type` | string | 任务类型:文本生成、文生图、图生图等 |
| `model` | string | 使用模型 |
| `channel` | string | 使用渠道 |
| `status` | string | 状态:排队中、执行中、成功、失败、取消等 |
| `credits` | number | 扣除算力点 |
| `input` | json | 请求参数 |
| `output` | json | 生成结果 |
| `error` | string | 错误信息 |
| `extra` | json | 扩展信息 |
| `created_at` | string | 创建时间 |
| `started_at` | string | 开始时间 |
| `finished_at` | string | 完成时间 |
| 值 | 说明 |
| --- | --- |
| `admin_adjust` | 后台手动调整 |
| `ai_consume` | 调用后端模型接口消费 |
| `ai_refund` | 后端模型接口调用失败返还 |
+7 -5
View File
@@ -9,8 +9,9 @@
- 画布项目 JSON`localForage`,数据库名 `infinite-canvas`storeName `app_state`key 为 `infinite-canvas:canvas_store`
- 我的素材 JSON`localForage`,数据库名 `infinite-canvas`storeName `app_state`key 为 `infinite-canvas:asset_store`
- 图片 Blob:单独存到 `localForage` 实例,数据库名 `infinite-canvas`storeName `image_files`
- 视频等媒体 Blob:单独存到 `localForage` 实例,数据库名 `infinite-canvas`storeName `media_files`
画布 JSON 不直接长期保存大体积 base64 图片。图片节点、助手图片和素材图片只保存展示 URL、`storageKey`图片元信息,真实图片 Blob 通过 `storageKey` 读取。
画布 JSON 不直接长期保存大体积 base64 图片或视频。图片节点、视频节点、助手图片和素材媒体只保存展示 URL、`storageKey` 和元信息,真实 Blob 通过 `storageKey` 读取。
## 画布项目结构
@@ -50,7 +51,7 @@ type CanvasProject = {
```ts
type CanvasNodeData = {
id: string;
type: "image" | "text" | "config";
type: "image" | "text" | "config" | "video";
title: string;
position: { x: number; y: number };
width: number;
@@ -62,7 +63,7 @@ type CanvasNodeData = {
通用字段:
- `id`:节点 ID。
- `type`:节点类型,当前有图片、文本、生成配置类。
- `type`:节点类型,当前有图片、文本、生成配置、视频四类。
- `title`:节点标题。
- `position`:画布世界坐标,不是屏幕坐标。
- `width` / `height`:画布世界坐标下的节点尺寸。
@@ -77,7 +78,7 @@ type CanvasNodeMetadata = {
status?: "idle" | "success" | "loading" | "error";
errorDetails?: string;
fontSize?: number;
generationMode?: "text" | "image";
generationMode?: "text" | "image" | "video";
model?: string;
size?: string;
count?: number;
@@ -99,8 +100,9 @@ type CanvasNodeMetadata = {
不同节点的使用方式:
- 图片节点:`content` 是当前可展示的图片 URL,通常是 `blob:` URL`storageKey` 指向本地图片 Blob`naturalWidth/naturalHeight/bytes/mimeType` 保存原图信息。
- 视频节点:`content` 是当前可播放的视频 URL,通常是 `blob:` URL`storageKey` 指向本地视频 Blob`bytes/mimeType` 保存文件信息。
- 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;上游输入通过 `connections` 计算。
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;`generationMode` 可选择文本、图片或视频;上游输入通过 `connections` 计算。
- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。
## 连线结构
+13 -1
View File
@@ -20,7 +20,7 @@
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
- 输入内容可以手写,也可以从提示词库选择。
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
### 用文本节点生成图片
@@ -39,6 +39,18 @@
- 每个生成出来的图片节点都会在自身 `metadata` 下记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,方便后续重试。
- 如果重试时参考图已经丢失或无法恢复,系统会直接提示,避免误发空的图生图请求。
### 视频节点
- 工具栏可以新建视频节点,也可以拖入或上传本地视频文件。
- 视频节点使用原生播放器展示内容,可在节点内直接播放、暂停和拖动进度。
- 空视频节点下方对话框可输入提示词生成视频,结果会回填到当前节点。
- 从文本、图片或配置节点创建视频生成时,会在右侧生成新的视频节点并自动连接。
- 生成配置节点的视频模式会读取上游文本作为 prompt,读取上游图片作为参考图,读取上游视频作为参考视频,并在输入预览里显示参考视频。
- 视频生成接口支持 OpenAI 风格的 `POST /v1/videos``GET /v1/videos/{id}``GET /v1/videos/{id}/content`
- 使用火山方舟 Agent Plan / Seedance 2.0 时,Base URL 配置为 `https://ark.cn-beijing.volces.com/api/plan/v3`,模型名使用 Seedance 2.0 对应模型;系统会改用 `POST /contents/generations/tasks` 创建异步任务,并轮询 `GET /contents/generations/tasks/{id}`
- Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,后台不会伪造模型列表;请手动填写 `doubao-seedance-2.0` 或文档列出的其他可用模型。
- Seedance 参考视频必须是公网可访问 URL,或由本项目后端在配置 `PUBLIC_BASE_URL` 后上传并暴露的参考素材 URL。本地/内网地址无法被火山服务器拉取。
### 推荐流程
1. 新建文本节点,写入图片创作想法。
+23 -3
View File
@@ -52,12 +52,26 @@
## AI 生成
项目通过前端直接请求 OpenAI 兼容接口
项目支持两种 AI 调用方式
- 本地直连:前端使用本地配置的 Base URL、API Key 和 Model 直接请求 OpenAI 兼容接口。
- 后台渠道:前端请求本项目后端 `/api/v1/*` 代理接口,后端按模型选择管理后台配置的渠道。
OpenAI 兼容图像和文本能力继续复用现有接口:
- `/v1/images/generations`:文生图。
- `/v1/images/edits`:图生图/参考图编辑。
- `/v1/chat/completions`:文本问答和带图问答。
- `/v1/models`:读取模型列表。
- `/v1/models`:读取模型列表;火山方舟 Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,需要手动填写模型名
视频能力支持两类接口:
- OpenAI 风格视频:`POST /v1/videos``GET /v1/videos/{id}``GET /v1/videos/{id}/content`
- 火山方舟 Agent Plan / Seedance 2.0Base URL 使用 `https://ark.cn-beijing.volces.com/api/plan/v3`,创建任务为 `POST /contents/generations/tasks`,查询任务为 `GET /contents/generations/tasks/{id}`,成功结果读取 `content.video_url`
Base URL 如果已经以 `/v1``/api/v3``/api/plan/v3` 结尾,系统不会再追加 `/v1`。因此 cpa 反代或火山方舟 Agent Plan 可以继续通过现有 Base URL + API Key + Model 方式配置,不需要新增火山生图 Provider。
后台“拉取模型列表”会尝试真实请求 OpenAI `/models`,不会为 Agent Plan 伪造模型结果。如果火山方舟 Agent Plan 返回 404,请手动增加 `doubao-seedance-2.0` 或文档列出的其他模型名。
可配置项:
@@ -67,9 +81,13 @@
- 图片质量。
- 图片比例。
- 生成数量。
- 视频模型。
- 视频比例、清晰度、时长、生成声音和水印。
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p``720p``1080p`fast 模型不支持 `1080p`),比例支持 `16:9``4:3``1:1``3:4``9:16``21:9``adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。Seedance 参考视频和参考音频需要公网可访问 URL;本地上传素材会先通过 `/api/v1/media/references` 保存到服务端,再由 `PUBLIC_BASE_URL` 生成可供火山服务器拉取的公开链接。
## 画布助手
画布右侧助手面板支持:
@@ -161,6 +179,8 @@
## 当前限制
- 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。
- AI API Key 目前保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口。
- 本地直连模式下,AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合本地或个人使用,公网多人使用不安全。公网部署推荐使用后台渠道,把真实密钥保存在服务端配置中
- 服务器素材库目前主要保存 URL 或文本,暂未提供文件上传接口。
- Seedance 本地参考图/视频上传依赖 `PUBLIC_BASE_URL`,如果服务部署在 localhost、内网或不可被火山访问的地址,火山无法拉取参考素材。
- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
+34 -1
View File
@@ -1,3 +1,36 @@
# 待测试
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl``apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入
- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1``图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。
- GPT Image 生图请求会在前端把 `9:16``16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`
- Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。
- Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。
- Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
- 修复删除画布图片节点或清空画布后撤销时,节点信息恢复但本地图片数据已被清理导致图片丢失的问题。
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name``preset=normal``input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
- 画布视频设置浮层改为挂载到页面根层级并使用自建浮层交互,避免被节点悬浮工具栏遮挡或点击面板内容时关闭。
- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
- 火山方舟 Agent Plan / Seedance 2.0 视频生成需要在真实账号下验证:`/contents/generations/tasks` 创建任务、轮询状态、`content.video_url` 回填画布,以及 401/403/429/超时错误提示。
- 管理后台保存私有渠道后,需要验证所有已启用渠道里的模型会自动出现在公开 `availableModels`,并且 `defaultVideoModel``defaultImageModel``defaultTextModel` 在为空或失效时会自动修复,前台不再显示旧的 `grok` 默认值。
- `/video` 和画布视频设置已按 Seedance 2.0 增加分辨率、比例、4-15 秒/智能时长、生成声音和水印参数;需要在真实浏览器里验证参数回填、生成记录和画布节点配置都能保持一致。
- `/video` 支持最多 9 张参考图、3 个参考视频、3 个参考音频;需要验证格式、大小、音频时长提示和生成请求中的 `reference_image``reference_video``reference_audio` 组装。
- 画布新增音频节点,支持上传、拖入、播放、移动、缩放、删除,并可作为上游参考音频参与 Seedance 视频生成;需要验证刷新后本地音频 URL 能恢复。
- `PUBLIC_BASE_URL` 已配置公网域名时,需要验证本地上传参考视频和参考音频能被火山拉取;未配置或配置为内网地址时,需要验证前端能给出明确提示。
- Seedance 返回远程视频 URL 但浏览器无法下载为 Blob 时,需要验证视频节点刷新后仍保留远程 URL,并确认上游 URL 有效期限制。
+44 -5
View File
@@ -13,11 +13,21 @@
{
"modelChannel": {
"availableModels": ["gpt-5.5", "gpt-image-2"],
"modelCosts": [
{ "model": "gpt-5.5", "credits": 1 },
{ "model": "gpt-image-2", "credits": 10 }
],
"defaultModel": "gpt-image-2",
"defaultImageModel": "gpt-image-2",
"defaultTextModel": "gpt-5.5",
"systemPrompt": "",
"allowCustomChannel": true
},
"auth": {
"allowRegister": true,
"linuxDo": {
"enabled": false
}
}
}
```
@@ -25,18 +35,28 @@
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `modelChannel` | object | 模型渠道公开配置组 |
| `auth` | object | 认证相关公开配置 |
`modelChannel` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 |
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 |
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
| `availableModels` | string[] | 系统可用模型;保存设置时会自动合并所有已启用私有渠道模型 |
| `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 |
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择;为空或失效时优先选择文本模型 |
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedream``image``gpt-image` 模型 |
| `defaultVideoModel` | string | 默认视频模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedance``video` 模型 |
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择;为空或失效时优先选择非图片/视频模型 |
| `systemPrompt` | string | 系统提示词 |
| `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 |
`modelCosts` 每项字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `model` | string | 模型名称 |
| `credits` | number | 每次后端模型接口调用前预扣的算力点 |
用户侧请求模式:
| 模式 | 说明 |
@@ -44,6 +64,13 @@
| 云端渠道 | 使用后端 `/api/v1/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
| 本地直连 | 默认可选;`allowCustomChannel` 关闭后不可选,用户在浏览器本地配置 `baseUrl``apiKey` 和模型列表后直接请求模型接口 |
`auth` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `allowRegister` | boolean | 是否允许用户注册,默认允许;关闭后注册入口隐藏,注册接口拒绝新用户创建 |
| `linuxDo.enabled` | boolean | 是否开启 Linux.do 登录 |
## private.value
```json
@@ -59,13 +86,18 @@
"enabled": true,
"remark": ""
}
]
],
"promptSync": {
"enabled": true,
"cron": "*/5 * * * *"
}
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `channels` | object[] | 模型渠道列表 |
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
`channels` 每项字段:
@@ -81,3 +113,10 @@
| `remark` | string | 备注 |
后端调用模型时,会从已启用、已配置 `baseUrl``apiKey`、且 `models` 包含目标模型的渠道中选择一个。
`promptSync` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | boolean | 是否开启定时同步,默认开启 |
| `cron` | string | Cron 表达式,默认每 5 分钟 |
+1
View File
@@ -7,3 +7,4 @@
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
| https://github.com/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 | 已实现同步逻辑 |
-42
View File
@@ -1,45 +1,3 @@
# TODO
本文档用来记录当前项目后续比较值得处理的事项。
## P0 近期优先
暂无。
## P1 核心账号和后台
- 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID
等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。
- 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。
- 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。
- 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。
- 画布助手对话框优化:把当前对话面板调整得更简洁,减少干扰,突出输入、引用和结果。
- 考虑引入视频生成能力
## P1 算力点和商业化
- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`
- 算力点订阅模块:订阅套餐配置先放 `settings.public.value`,用户订阅记录新增 `subscriptions`
表;用户购买订阅后,模型调用优先从可用订阅额度扣除,不足时再扣用户余额。
- 支付模块:新增 `orders` 表,统一记录充值、订阅购买等订单,预留 Linux LDC 支付和第三方聚合支付;该模块优先级较低,先只设计结构,暂不重点实现。
- 邀请奖励模块:邀请码、邀请人和邀请人数放 `users`,注册奖励、充值奖励配置放 `settings.private.value`,奖励发放记录写入
`credit_logs`
## P1 内容、素材和画布
- 文件存储管理:新增 `files` 表和存储接口,文件表只记录最终可访问 URL 等基础信息;后续再考虑图片缩略图、文件清理等能力,后台可查询所有图片和文件。
- 提示词管理:新增 `prompts` 表,公开提示词、内置 GitHub 系统提示词、分类和扩展信息等尽量放字段或 JSON,支持后台维护和随时爬取更新。
- 提示词分类:已移除 `prompt_groups` 表;分类数据很少,并且必须和抓取代码一一对应,直接在代码内存里写死维护。
- 画布管理:新增 `canvases` 表,后台管理员可以查看每个人的画布数据,普通用户可以查看自己的画布列表。
- 公开画布:在 `canvases` 表增加公开状态、审核状态、公开信息等字段;管理员可将自己的画布设为公开,用户也可以申请公开并由管理员审核。
- 画布分享与模板:在云端画布基础上支持只读分享、复制为模板、团队内共享,分享配置和模板标记优先放在 `canvases` 字段或 JSON 中。
- 项目库:基于公开画布增加系统公开项目库,用于筛选、打开、复制公开画布项目。
- 画布协作能力:后续如果支持多人协作,协作成员、节点操作事件、锁定/光标、冲突处理和增量同步优先放在 `canvases` 的协作 JSON
中,确实不够用时再拆表。
- 素材管理和我的素材:新增 `assets` 表,用 `user_id``visibility``type` 区分系统公开素材和用户私有素材;管理员可管理公开素材,也可以查看每个用户自己的素材。
- 素材互动:公开素材增加点赞量、收藏量、查看量等统计字段;用户侧我的收藏、我的点赞先放在 `assets` 或用户偏好 JSON
中,后续数据量大了再拆互动表。
## P2 体验和工程
- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。
+1
View File
@@ -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
+2
View File
@@ -83,6 +83,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-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=
+14
View File
@@ -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)
+191 -9
View File
@@ -3,6 +3,7 @@ package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime"
@@ -25,12 +26,22 @@ func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/chat/completions")
}
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
body, contentType, modelName, err := readAIRequest(r)
if err != nil {
log.Printf("AI proxy request read failed: %v", err)
Fail(w, "AI 接口请求失败")
return
func AIVideos(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/videos")
}
func AIVideo(w http.ResponseWriter, r *http.Request, id string) {
proxyAIGetRequest(w, r, "/videos/"+id)
}
func AIVideoContent(w http.ResponseWriter, r *http.Request, id string) {
proxyAIGetRequest(w, r, "/videos/"+id+"/content")
}
func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) {
modelName := r.URL.Query().Get("model")
if strings.TrimSpace(modelName) == "" {
modelName = "grok-imagine-video"
}
channel, err := service.SelectModelChannel(modelName)
if err != nil {
@@ -38,6 +49,42 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
Fail(w, "AI 接口请求失败")
return
}
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
request, err := http.NewRequest(http.MethodGet, service.BuildModelChannelURL(channel, path), nil)
if err != nil {
Fail(w, "AI 接口请求失败")
return
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
copyAIResponse(w, request, nil)
}
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
body, contentType, modelName, err := readAIRequest(r)
if err != nil {
log.Printf("AI proxy request read failed: %v", err)
Fail(w, "AI 接口请求失败")
return
}
user, ok := service.UserFromContext(r.Context())
if !ok {
Fail(w, "未登录或权限不足")
return
}
credits, err := service.ModelCost(modelName)
if err != nil {
log.Printf("AI proxy read model cost failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
credits *= readAIRequestCount(body, contentType)
channel, err := service.SelectModelChannel(modelName)
if err != nil {
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
if err != nil {
log.Printf("AI proxy build request failed: url=%s err=%v", service.BuildModelChannelURL(channel, path), err)
@@ -48,18 +95,36 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if err := service.ConsumeUserCredits(user.ID, modelName, credits, path); err != nil {
FailError(w, err)
return
}
copyAIResponse(w, request, func() {
if err := service.RefundUserCredits(user.ID, modelName, credits, path); err != nil {
log.Printf("AI proxy refund credits failed: user=%s model=%s credits=%d err=%v", user.ID, modelName, credits, err)
}
})
}
func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func()) {
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
if onFailure != nil {
onFailure()
}
Fail(w, "AI 接口请求失败")
return
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload)))
Fail(w, "AI 接口请求失败")
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d", request.URL.String(), response.StatusCode)
if onFailure != nil {
onFailure()
}
Fail(w, aiUpstreamStatusMessage(response.StatusCode, body))
return
}
@@ -114,8 +179,125 @@ func readMultipartModel(body []byte, contentType string) string {
return ""
}
func readAIRequestCount(body []byte, contentType string) int {
count := 1
if strings.HasPrefix(contentType, "multipart/form-data") {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return count
}
form, err := multipart.NewReader(bytes.NewReader(body), params["boundary"]).ReadForm(32 << 20)
if err != nil {
return count
}
defer form.RemoveAll()
if values := form.Value["n"]; len(values) > 0 {
_, _ = fmt.Sscan(values[0], &count)
}
} else {
var payload struct {
N int `json:"n"`
}
_ = json.Unmarshal(body, &payload)
count = payload.N
}
if count < 1 {
return 1
}
return count
}
var errMissingModel = &aiError{"缺少模型名称"}
func resolveAIProxyPath(baseURL string, modelName string, path string) string {
if !isArkSeedanceVideo(baseURL, modelName) {
return path
}
if path == "/videos" {
return "/contents/generations/tasks"
}
if strings.HasPrefix(path, "/videos/") && !strings.HasSuffix(path, "/content") {
return "/contents/generations/tasks/" + strings.TrimPrefix(path, "/videos/")
}
return path
}
func isArkSeedanceVideo(baseURL string, modelName string) bool {
base := strings.ToLower(baseURL)
model := strings.ToLower(modelName)
return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || strings.Contains(base, "/api/plan/v3")
}
func aiStatusMessage(statusCode int) string {
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return "AI 接口鉴权失败,请检查 API Key、套餐权限或模型权限"
case http.StatusTooManyRequests:
return "AI 接口限流或额度不足,请稍后重试或检查额度"
default:
return "AI 接口请求失败"
}
}
func aiUpstreamStatusMessage(statusCode int, body []byte) string {
base := aiStatusMessage(statusCode)
detail := aiUpstreamErrorDetail(body)
if detail == "" {
return base
}
return base + "" + detail
}
func aiUpstreamErrorDetail(body []byte) string {
text := strings.TrimSpace(string(body))
if text == "" {
return ""
}
var payload struct {
Msg string `json:"msg"`
Message string `json:"message"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &payload); err == nil {
if payload.Error.Message != "" {
if detail := friendlyUpstreamError(payload.Error.Code, payload.Error.Message); detail != "" {
return safeUpstreamText(detail)
}
if payload.Error.Code != "" {
return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message)
}
return safeUpstreamText(payload.Error.Message)
}
if payload.Msg != "" {
return safeUpstreamText(payload.Msg)
}
if payload.Message != "" {
return safeUpstreamText(payload.Message)
}
}
return safeUpstreamText(text)
}
func friendlyUpstreamError(code string, message string) string {
lowerCode := strings.ToLower(strings.TrimSpace(code))
if strings.Contains(lowerCode, "inputvideosensitivecontentdetected") || strings.Contains(lowerCode, "privacyinformation") {
return strings.TrimSpace(code + " 参考视频疑似包含真人或隐私信息,火山方舟拒绝使用普通 URL 作为真人视频参考;请改用不含真人的视频、官方允许的模型产物,或已授权的 asset:// 素材。原始错误:" + message)
}
return ""
}
func safeUpstreamText(text string) string {
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
runes := []rune(text)
if len(runes) > 300 {
return string(runes[:300]) + "..."
}
return text
}
type aiError struct {
message string
}
+27
View File
@@ -0,0 +1,27 @@
package handler
import (
"strings"
"testing"
)
func TestAIUpstreamErrorDetail(t *testing.T) {
got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InvalidParameter","message":"reference video fps is invalid"}}`))
if got != "InvalidParameter reference video fps is invalid" {
t.Fatalf("detail = %q", got)
}
}
func TestAIUpstreamErrorDetailExplainsSensitiveVideo(t *testing.T) {
got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InputVideoSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input video may contain real person."}}`))
if !strings.Contains(got, "参考视频疑似包含真人") || !strings.Contains(got, "asset://") {
t.Fatalf("detail = %q", got)
}
}
func TestSafeUpstreamTextTruncates(t *testing.T) {
got := safeUpstreamText(strings.Repeat("错", 320))
if len([]rune(got)) != 303 {
t.Fatalf("truncated rune length = %d", len([]rune(got)))
}
}
+89 -10
View File
@@ -3,6 +3,8 @@ package handler
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/service"
@@ -19,10 +21,17 @@ type registerRequest struct {
}
type saveUserRequest struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Role model.UserRole `json:"role"`
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role model.UserRole `json:"role"`
Status model.UserStatus `json:"status"`
}
type adjustUserCreditsRequest struct {
Credits int `json:"credits"`
}
func Register(w http.ResponseWriter, r *http.Request) {
@@ -44,11 +53,25 @@ func Login(w http.ResponseWriter, r *http.Request) {
FailError(w, err)
return
}
if session.User.Role != model.UserRoleAdmin {
Fail(w, "需要管理员权限")
OK(w, session)
}
func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) {
authURL, err := service.LinuxDoAuthorizeURL(r, r.URL.Query().Get("redirect"))
if err != nil {
FailError(w, err)
return
}
OK(w, session)
http.Redirect(w, r, authURL, http.StatusFound)
}
func LinuxDoCallback(w http.ResponseWriter, r *http.Request) {
session, redirect, err := service.LoginWithLinuxDo(r, r.URL.Query().Get("code"), r.URL.Query().Get("state"))
if err != nil {
http.Redirect(w, r, loginRedirect(r, redirect, "", err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, loginRedirect(r, redirect, session.Token, ""), http.StatusFound)
}
func AdminLogin(w http.ResponseWriter, r *http.Request) {
@@ -87,9 +110,12 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
var request saveUserRequest
_ = json.NewDecoder(r.Body).Decode(&request)
user, err := service.SaveUser(model.User{
ID: request.ID,
Username: request.Username,
Role: request.Role,
ID: request.ID,
Username: request.Username,
Email: request.Email,
DisplayName: request.DisplayName,
Role: request.Role,
Status: request.Status,
}, request.Password)
if err != nil {
FailError(w, err)
@@ -98,6 +124,59 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
OK(w, user)
}
func AdminAdjustUserCredits(w http.ResponseWriter, r *http.Request, id string) {
var request adjustUserCreditsRequest
_ = json.NewDecoder(r.Body).Decode(&request)
user, err := service.AdjustUserCredits(id, request.Credits)
if err != nil {
FailError(w, err)
return
}
OK(w, user)
}
func AdminCreditLogs(w http.ResponseWriter, r *http.Request) {
logs, err := service.ListCreditLogs(parseQuery(r))
if err != nil {
FailError(w, err)
return
}
OK(w, logs)
}
func AdminSaveCreditLog(w http.ResponseWriter, r *http.Request) {
var log model.CreditLog
_ = json.NewDecoder(r.Body).Decode(&log)
result, err := service.SaveCreditLog(log)
if err != nil {
FailError(w, err)
return
}
OK(w, result)
}
func AdminDeleteCreditLog(w http.ResponseWriter, r *http.Request, id string) {
if err := service.DeleteCreditLog(id); err != nil {
FailError(w, err)
return
}
OK(w, true)
}
func loginRedirect(r *http.Request, redirect string, token string, message string) string {
values := url.Values{}
if strings.TrimSpace(token) != "" {
values.Set("token", token)
}
if strings.TrimSpace(message) != "" {
values.Set("error", message)
}
if strings.TrimSpace(redirect) != "" {
values.Set("redirect", redirect)
}
return service.RequestOrigin(r) + "/login?" + values.Encode()
}
func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) {
if err := service.DeleteUser(id); err != nil {
FailError(w, err)
+238
View File
@@ -0,0 +1,238 @@
package handler
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/basketikun/infinite-canvas/config"
"github.com/google/uuid"
)
const (
referenceMediaMaxBytes = 80 << 20
referenceImageMaxBytes = 30 << 20
referenceVideoMaxBytes = 50 << 20
referenceAudioMaxBytes = 15 << 20
referenceImageAllowedText = "jpeg/png/webp/bmp/gif/heic/heif 图片"
referenceVideoAllowedText = "mp4/mov 视频"
referenceAudioAllowedText = "mp3/wav 音频"
referenceMediaAllowedText = referenceImageAllowedText + "、" + referenceVideoAllowedText + "或" + referenceAudioAllowedText
)
type referenceMediaUploadResult struct {
ID string `json:"id"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
Bytes int64 `json:"bytes"`
}
func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) {
publicBaseURL := strings.TrimRight(strings.TrimSpace(config.Cfg.PublicBaseURL), "/")
if publicBaseURL == "" {
Fail(w, "未配置 PUBLIC_BASE_URL,无法把本地参考素材提供给火山方舟访问")
return
}
r.Body = http.MaxBytesReader(w, r.Body, referenceMediaMaxBytes+1)
if err := r.ParseMultipartForm(referenceMediaMaxBytes); err != nil {
Fail(w, "参考素材过大或上传格式不正确")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
file, header, err := r.FormFile("file")
if err != nil {
Fail(w, "请上传参考图片或视频")
return
}
defer file.Close()
mimeType, ext, ok := normalizeReferenceMediaType(header.Header.Get("Content-Type"), filepath.Ext(header.Filename))
if !ok {
Fail(w, "参考素材格式不支持,请使用 "+referenceMediaAllowedText)
return
}
if err := os.MkdirAll(referenceMediaDir(), 0o755); err != nil {
Fail(w, "参考素材保存失败")
return
}
id := uuid.NewString() + ext
targetPath := filepath.Join(referenceMediaDir(), id)
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
Fail(w, "参考素材保存失败")
return
}
bytes, copyErr := io.Copy(target, file)
closeErr := target.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(targetPath)
Fail(w, "参考素材保存失败")
return
}
if bytes <= 0 {
_ = os.Remove(targetPath)
Fail(w, "参考素材为空")
return
}
if limit := referenceMediaTypeMaxBytes(mimeType); limit > 0 && bytes > limit {
_ = os.Remove(targetPath)
Fail(w, referenceMediaSizeMessage(mimeType))
return
}
OK(w, referenceMediaUploadResult{
ID: id,
URL: fmt.Sprintf("%s/api/media/references/%s", publicBaseURL, id),
MimeType: mimeType,
Bytes: bytes,
})
}
func ReferenceMedia(w http.ResponseWriter, r *http.Request, id string) {
if id == "" || id != filepath.Base(id) || strings.Contains(id, "..") {
http.NotFound(w, r)
return
}
path := filepath.Join(referenceMediaDir(), id)
file, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
if mimeType := mimeTypeByReferenceMediaExt(filepath.Ext(id)); mimeType != "" {
w.Header().Set("Content-Type", mimeType)
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeContent(w, r, id, info.ModTime(), file)
}
func referenceMediaDir() string {
return filepath.Join(referenceDataDir(), "reference-media")
}
func referenceDataDir() string {
driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver))
dsn := strings.TrimSpace(config.Cfg.DatabaseDSN)
if (driver == "" || driver == "sqlite") && dsn != "" && dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") {
pathPart := dsn
if index := strings.Index(dsn, "?"); index >= 0 {
pathPart = dsn[:index]
}
if filepath.IsAbs(pathPart) {
return filepath.Dir(pathPart)
}
}
if _, err := os.Stat("/app/data"); err == nil {
return "/app/data"
}
return "data"
}
func normalizeReferenceMediaType(contentType string, ext string) (string, string, bool) {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
ext = strings.ToLower(strings.TrimSpace(ext))
if contentType == "" || contentType == "application/octet-stream" {
contentType = mimeTypeByReferenceMediaExt(ext)
}
if fixedExt := referenceMediaExtByMimeType(contentType); fixedExt != "" {
return contentType, fixedExt, true
}
if mimeType := mimeTypeByReferenceMediaExt(ext); mimeType != "" {
return mimeType, ext, true
}
return "", "", false
}
func referenceMediaExtByMimeType(mimeType string) string {
switch strings.ToLower(mimeType) {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/bmp":
return ".bmp"
case "image/gif":
return ".gif"
case "image/heic":
return ".heic"
case "image/heif":
return ".heif"
case "video/mp4":
return ".mp4"
case "video/quicktime", "video/mov":
return ".mov"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/wav", "audio/x-wav", "audio/wave":
return ".wav"
default:
return ""
}
}
func mimeTypeByReferenceMediaExt(ext string) string {
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".bmp":
return "image/bmp"
case ".gif":
return "image/gif"
case ".heic":
return "image/heic"
case ".heif":
return "image/heif"
case ".mp4":
return "video/mp4"
case ".mov":
return "video/quicktime"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
default:
return ""
}
}
func referenceMediaTypeMaxBytes(mimeType string) int64 {
if strings.HasPrefix(mimeType, "image/") {
return referenceImageMaxBytes
}
if strings.HasPrefix(mimeType, "video/") {
return referenceVideoMaxBytes
}
if strings.HasPrefix(mimeType, "audio/") {
return referenceAudioMaxBytes
}
return referenceMediaMaxBytes
}
func referenceMediaSizeMessage(mimeType string) string {
if strings.HasPrefix(mimeType, "image/") {
return "参考图片超过大小限制,请使用 30MB 以内的图片"
}
if strings.HasPrefix(mimeType, "video/") {
return "参考视频超过大小限制,请使用 50MB 以内的 mp4/mov 视频"
}
if strings.HasPrefix(mimeType, "audio/") {
return "参考音频超过大小限制,请使用 15MB 以内的 mp3/wav 音频"
}
return "参考素材超过大小限制"
}
+56
View File
@@ -0,0 +1,56 @@
package handler
import (
"path/filepath"
"testing"
"github.com/basketikun/infinite-canvas/config"
)
func TestNormalizeReferenceMediaTypeSupportsAudio(t *testing.T) {
tests := []struct {
name string
contentType string
ext string
wantMime string
wantExt string
}{
{name: "mp3 mime", contentType: "audio/mpeg", ext: ".bin", wantMime: "audio/mpeg", wantExt: ".mp3"},
{name: "wav ext fallback", contentType: "application/octet-stream", ext: ".wav", wantMime: "audio/wav", wantExt: ".wav"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mimeType, ext, ok := normalizeReferenceMediaType(tt.contentType, tt.ext)
if !ok {
t.Fatal("expected media type to be accepted")
}
if mimeType != tt.wantMime || ext != tt.wantExt {
t.Fatalf("got (%q, %q), want (%q, %q)", mimeType, ext, tt.wantMime, tt.wantExt)
}
})
}
}
func TestReferenceMediaTypeMaxBytes(t *testing.T) {
if got := referenceMediaTypeMaxBytes("audio/mpeg"); got != referenceAudioMaxBytes {
t.Fatalf("audio max bytes = %d, want %d", got, referenceAudioMaxBytes)
}
if got := referenceMediaTypeMaxBytes("video/mp4"); got != referenceVideoMaxBytes {
t.Fatalf("video max bytes = %d, want %d", got, referenceVideoMaxBytes)
}
if got := referenceMediaTypeMaxBytes("image/png"); got != referenceImageMaxBytes {
t.Fatalf("image max bytes = %d, want %d", got, referenceImageMaxBytes)
}
}
func TestReferenceMediaDirUsesAbsoluteSQLiteDataDir(t *testing.T) {
previous := config.Cfg
t.Cleanup(func() { config.Cfg = previous })
root := t.TempDir()
config.Cfg = config.Config{StorageDriver: "sqlite", DatabaseDSN: filepath.Join(root, "infinite-canvas.db")}
if got := referenceMediaDir(); got != filepath.Join(root, "reference-media") {
t.Fatalf("referenceMediaDir = %q", got)
}
}
+1
View File
@@ -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))
}
+11
View File
@@ -21,6 +21,17 @@ func AdminAuth(c *gin.Context) {
c.Next()
}
func UserAuth(c *gin.Context) {
user, ok := authUser(c)
if !ok || user.Role == model.UserRoleGuest {
handler.Fail(c.Writer, "未登录或权限不足")
c.Abort()
return
}
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
c.Next()
}
func OptionalAuth(c *gin.Context) {
if user, ok := authUser(c); ok {
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
+42 -7
View File
@@ -21,24 +21,59 @@ type ModelChannel struct {
Remark string `json:"remark"`
}
// ModelCost 模型算力点配置。
type ModelCost struct {
Model string `json:"model"`
Credits int `json:"credits"`
}
// PublicModelChannelSetting 公开模型渠道配置。
type PublicModelChannelSetting struct {
AvailableModels []string `json:"availableModels"`
DefaultModel string `json:"defaultModel"`
DefaultImageModel string `json:"defaultImageModel"`
DefaultTextModel string `json:"defaultTextModel"`
SystemPrompt string `json:"systemPrompt"`
AllowCustomChannel *bool `json:"allowCustomChannel"`
AvailableModels []string `json:"availableModels"`
ModelCosts []ModelCost `json:"modelCosts"`
DefaultModel string `json:"defaultModel"`
DefaultImageModel string `json:"defaultImageModel"`
DefaultVideoModel string `json:"defaultVideoModel"`
DefaultTextModel string `json:"defaultTextModel"`
SystemPrompt string `json:"systemPrompt"`
AllowCustomChannel *bool `json:"allowCustomChannel"`
}
// PublicSetting 公开配置。
type PublicSetting struct {
ModelChannel PublicModelChannelSetting `json:"modelChannel"`
Auth PublicAuthSetting `json:"auth"`
}
type PublicAuthSetting struct {
AllowRegister *bool `json:"allowRegister"`
LinuxDo PublicLinuxDoAuthSetting `json:"linuxDo"`
}
type PublicLinuxDoAuthSetting struct {
Enabled bool `json:"enabled"`
}
// PrivateSetting 私有配置。
type PrivateSetting struct {
Channels []ModelChannel `json:"channels"`
Channels []ModelChannel `json:"channels"`
PromptSync PromptSyncSetting `json:"promptSync"`
Auth PrivateAuthSetting `json:"auth"`
}
// PromptSyncSetting 提示词定时同步配置。
type PromptSyncSetting struct {
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
}
type PrivateAuthSetting struct {
LinuxDo PrivateLinuxDoAuthSetting `json:"linuxDo"`
}
type PrivateLinuxDoAuthSetting struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
// Setting 系统配置。
+68 -16
View File
@@ -8,14 +8,34 @@ const (
UserRoleAdmin UserRole = "admin"
)
type UserStatus string
const (
UserStatusActive UserStatus = "active"
UserStatusBan UserStatus = "ban"
)
// User 系统用户。
type User struct {
ID string `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"uniqueIndex"`
Password string `json:"password,omitempty"`
Role UserRole `json:"role"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"uniqueIndex"`
Password string `json:"password,omitempty"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
AvatarURL string `json:"avatarUrl"`
Role UserRole `json:"role"`
Credits int `json:"credits"`
AffCode string `json:"affCode" gorm:"uniqueIndex"`
AffCount int `json:"affCount"`
InviterID string `json:"inviterId"`
GithubID string `json:"githubId"`
LinuxDoID string `json:"linuxDoId" gorm:"index"`
WechatID string `json:"wechatId"`
Status UserStatus `json:"status"`
LastLoginAt string `json:"lastLoginAt"`
Extra string `json:"extra" gorm:"type:text"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// UserList 用户分页结果。
@@ -26,11 +46,14 @@ type UserList struct {
// AuthUser 用户公开信息。
type AuthUser struct {
ID string `json:"id"`
Username string `json:"username"`
Role UserRole `json:"role"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
AvatarURL string `json:"avatarUrl"`
Role UserRole `json:"role"`
Credits int `json:"credits"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// AuthSession 登录会话信息。
@@ -41,10 +64,39 @@ type AuthSession struct {
func PublicUser(user User) AuthUser {
return AuthUser{
ID: user.ID,
Username: user.Username,
Role: user.Role,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
AvatarURL: user.AvatarURL,
Role: user.Role,
Credits: user.Credits,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
type CreditLogType string
const (
CreditLogTypeAdminAdjust CreditLogType = "admin_adjust"
CreditLogTypeAIConsume CreditLogType = "ai_consume"
CreditLogTypeAIRefund CreditLogType = "ai_refund"
)
// CreditLog 用户算力点变更流水。
type CreditLog struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"userId" gorm:"index"`
Type CreditLogType `json:"type"`
Amount int `json:"amount"`
Balance int `json:"balance"`
RelatedID string `json:"relatedId"`
Remark string `json:"remark"`
Extra string `json:"extra" gorm:"type:text"`
CreatedAt string `json:"createdAt"`
}
type CreditLogList struct {
Items []CreditLog `json:"items"`
Total int `json:"total"`
}
+2
View File
@@ -21,6 +21,7 @@ var promptCategories = []model.PromptCategory{
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
{Category: "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 (
@@ -46,6 +47,7 @@ func DB() (*gorm.DB, error) {
}
dbErr = db.AutoMigrate(
&model.User{},
&model.CreditLog{},
&model.Prompt{},
&model.Asset{},
&model.Setting{},
+9
View File
@@ -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()
+91
View File
@@ -2,6 +2,7 @@ package repository
import (
"errors"
"strings"
"github.com/basketikun/infinite-canvas/model"
"gorm.io/gorm"
@@ -15,6 +16,10 @@ func ListUsers(q model.Query) ([]model.User, int64, error) {
}
q.Normalize()
tx := db.Model(&model.User{})
if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
like := "%" + keyword + "%"
tx = tx.Where("username LIKE ? OR display_name LIKE ? OR email LIKE ? OR linux_do_id LIKE ?", like, like, like, like)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
@@ -74,6 +79,83 @@ func SaveUser(user model.User) (model.User, error) {
return user, db.Save(&user).Error
}
func ConsumeUserCredits(id string, credits int, now string) (model.User, bool, error) {
db, err := DB()
if err != nil {
return model.User{}, false, err
}
if credits <= 0 {
user, ok, err := GetUserByID(id)
return user, ok, err
}
tx := db.Model(&model.User{}).Where("id = ? AND credits >= ?", id, credits).Updates(map[string]any{
"credits": gorm.Expr("credits - ?", credits),
"updated_at": now,
})
if tx.Error != nil {
return model.User{}, false, tx.Error
}
user, ok, err := GetUserByID(id)
return user, ok && tx.RowsAffected > 0, err
}
func RefundUserCredits(id string, credits int, now string) (model.User, bool, error) {
db, err := DB()
if err != nil {
return model.User{}, false, err
}
if credits <= 0 {
user, ok, err := GetUserByID(id)
return user, ok, err
}
tx := db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
"credits": gorm.Expr("credits + ?", credits),
"updated_at": now,
})
if tx.Error != nil {
return model.User{}, false, tx.Error
}
user, ok, err := GetUserByID(id)
return user, ok && tx.RowsAffected > 0, err
}
// SaveCreditLog 保存算力点变更流水。
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
db, err := DB()
if err != nil {
return log, err
}
return log, db.Save(&log).Error
}
func ListCreditLogs(q model.Query) ([]model.CreditLog, int64, error) {
db, err := DB()
if err != nil {
return nil, 0, err
}
q.Normalize()
tx := db.Model(&model.CreditLog{})
if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
like := "%" + keyword + "%"
tx = tx.Where("user_id LIKE ? OR type LIKE ? OR remark LIKE ? OR related_id LIKE ?", like, like, like, like)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []model.CreditLog
err = tx.Order("created_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&logs).Error
return logs, total, err
}
func DeleteCreditLog(id string) error {
db, err := DB()
if err != nil {
return err
}
return db.Delete(&model.CreditLog{}, "id = ?", id).Error
}
// DeleteUser 删除指定用户。
func DeleteUser(id string) error {
db, err := DB()
@@ -83,6 +165,15 @@ func DeleteUser(id string) error {
return db.Delete(&model.User{}, "id = ?", id).Error
}
// GetUserByLinuxDoID 根据 Linux.do ID 查询用户。
func GetUserByLinuxDoID(id string) (model.User, bool, error) {
db, err := DB()
if err != nil {
return model.User{}, false, err
}
return findUser(db, "linux_do_id = ?", id)
}
// findUser 查询单个用户,并将未命中转换为 ok=false。
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
user := model.User{}
+29 -3
View File
@@ -18,11 +18,28 @@ func New() *gin.Engine {
})
api.POST("/auth/register", gin.WrapF(handler.Register))
api.POST("/auth/login", gin.WrapF(handler.Login))
api.GET("/auth/linux-do/authorize", gin.WrapF(handler.LinuxDoAuthorize))
api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback))
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
api.GET("/settings", gin.WrapF(handler.Settings))
api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations))
api.POST("/v1/images/edits", gin.WrapF(handler.AIImagesEdits))
api.POST("/v1/chat/completions", gin.WrapF(handler.AIChatCompletions))
api.GET("/media/references/:id", func(c *gin.Context) {
handler.ReferenceMedia(c.Writer, c.Request, c.Param("id"))
})
api.HEAD("/media/references/:id", func(c *gin.Context) {
handler.ReferenceMedia(c.Writer, c.Request, c.Param("id"))
})
v1 := api.Group("/v1", middleware.UserAuth)
v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations))
v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits))
v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions))
v1.POST("/videos", gin.WrapF(handler.AIVideos))
v1.POST("/media/references", gin.WrapF(handler.UploadReferenceMedia))
v1.GET("/videos/:id", func(c *gin.Context) {
handler.AIVideo(c.Writer, c.Request, c.Param("id"))
})
v1.GET("/videos/:id/content", func(c *gin.Context) {
handler.AIVideoContent(c.Writer, c.Request, c.Param("id"))
})
api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
@@ -30,9 +47,17 @@ func New() *gin.Engine {
admin := api.Group("/admin", middleware.AdminAuth)
admin.GET("/users", gin.WrapF(handler.AdminUsers))
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
admin.POST("/users/:id/credits", func(c *gin.Context) {
handler.AdminAdjustUserCredits(c.Writer, c.Request, c.Param("id"))
})
admin.DELETE("/users/:id", func(c *gin.Context) {
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
})
admin.GET("/credit-logs", gin.WrapF(handler.AdminCreditLogs))
admin.POST("/credit-logs", gin.WrapF(handler.AdminSaveCreditLog))
admin.DELETE("/credit-logs/:id", func(c *gin.Context) {
handler.AdminDeleteCreditLog(c.Writer, c.Request, c.Param("id"))
})
admin.GET("/settings", gin.WrapF(handler.AdminSettings))
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
@@ -41,6 +66,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"))
})
+368 -11
View File
@@ -1,8 +1,15 @@
package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
@@ -21,6 +28,10 @@ type TokenClaims struct {
jwt.RegisteredClaims
}
type userExtra struct {
LinuxDo any `json:"linuxDo,omitempty"`
}
func EnsureDefaultAdmin() error {
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
return nil
@@ -39,6 +50,8 @@ func EnsureDefaultAdmin() error {
Username: strings.TrimSpace(config.Cfg.AdminUsername),
Password: hash,
Role: model.UserRoleAdmin,
AffCode: newAffCode(),
Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
@@ -46,19 +59,26 @@ func EnsureDefaultAdmin() error {
}
func Register(username string, password string) (model.AuthSession, error) {
return model.AuthSession{}, errors.New("注册功能暂时关闭")
settings, err := repository.GetSettings()
if err != nil {
return model.AuthSession{}, err
}
normalizedSettings := normalizeSettings(settings)
if normalizedSettings.Public.Auth.AllowRegister != nil && !*normalizedSettings.Public.Auth.AllowRegister {
return model.AuthSession{}, safeMessageError{message: "当前未开放注册"}
}
username = strings.TrimSpace(username)
if strings.ContainsAny(username, " \t\r\n") {
return model.AuthSession{}, errors.New("用户名不能包含空格")
return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
}
if username == "" || password == "" {
return model.AuthSession{}, errors.New("用户名和密码不能为空")
return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
}
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
if err != nil {
return model.AuthSession{}, err
}
return model.AuthSession{}, errors.New("用户名已存在")
return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
}
hash, err := hashPassword(password)
if err != nil {
@@ -69,6 +89,8 @@ func Register(username string, password string) (model.AuthSession, error) {
Username: username,
Password: hash,
Role: model.UserRoleUser,
AffCode: newAffCode(),
Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
@@ -84,11 +106,102 @@ func Login(username string, password string) (model.AuthSession, error) {
return model.AuthSession{}, err
}
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return model.AuthSession{}, errors.New("用户名或密码错误")
return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"}
}
if user.Status == model.UserStatusBan {
return model.AuthSession{}, safeMessageError{message: "账号已被禁用"}
}
normalizeUserDefaults(&user)
user.LastLoginAt = now()
user.UpdatedAt = now()
user, err = repository.SaveUser(user)
if err != nil {
return model.AuthSession{}, err
}
return newSession(user)
}
func LinuxDoAuthorizeURL(r *http.Request, redirect string) (string, error) {
settings, err := repository.GetSettings()
if err != nil {
return "", err
}
settings = normalizeSettings(settings)
linuxDo := settings.Private.Auth.LinuxDo
if !settings.Public.Auth.LinuxDo.Enabled {
return "", safeMessageError{message: "Linux.do 登录未开启"}
}
if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" {
return "", safeMessageError{message: "Linux.do 登录未配置"}
}
values := url.Values{}
values.Set("client_id", linuxDo.ClientID)
values.Set("redirect_uri", linuxDoRedirectURI(r))
values.Set("response_type", "code")
values.Set("scope", "read")
values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect)))
return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil
}
func LoginWithLinuxDo(r *http.Request, code string, state string) (model.AuthSession, string, error) {
redirect := decodeState(state)
settings, err := repository.GetSettings()
if err != nil {
return model.AuthSession{}, redirect, err
}
settings = normalizeSettings(settings)
linuxDo := settings.Private.Auth.LinuxDo
if !settings.Public.Auth.LinuxDo.Enabled {
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"}
}
token, err := linuxDoAccessToken(r, code, linuxDo)
if err != nil {
return model.AuthSession{}, redirect, err
}
profile, err := linuxDoProfile(token)
if err != nil {
return model.AuthSession{}, redirect, err
}
linuxDoID := fmt.Sprint(profile.ID)
if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" {
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"}
}
user, ok, err := repository.GetUserByLinuxDoID(linuxDoID)
if err != nil {
return model.AuthSession{}, redirect, err
}
if !ok {
if settings.Public.Auth.AllowRegister != nil && !*settings.Public.Auth.AllowRegister {
return model.AuthSession{}, redirect, safeMessageError{message: "当前未开放注册"}
}
user = model.User{
ID: newID("user"),
Username: linuxDoUsername(profile.Username, linuxDoID),
DisplayName: strings.TrimSpace(profile.Name),
AvatarURL: linuxDoAvatar(profile.AvatarTemplate),
Role: model.UserRoleUser,
AffCode: newAffCode(),
LinuxDoID: linuxDoID,
Status: model.UserStatusActive,
CreatedAt: now(),
}
} else if user.Status == model.UserStatusBan {
return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"}
}
user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName)
user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
user.LastLoginAt = now()
user.UpdatedAt = now()
extra, _ := json.Marshal(userExtra{LinuxDo: profile})
user.Extra = string(extra)
user, err = repository.SaveUser(user)
if err != nil {
return model.AuthSession{}, redirect, err
}
session, err := newSession(user)
return session, redirect, err
}
func ParseToken(tokenText string) (TokenClaims, error) {
claims := TokenClaims{}
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
@@ -112,6 +225,9 @@ func CurrentAuthUser(tokenText string) (model.AuthUser, bool) {
if err != nil || !ok {
return model.AuthUser{}, false
}
if user.Status == model.UserStatusBan {
return model.AuthUser{}, false
}
return model.PublicUser(user), true
}
@@ -122,6 +238,7 @@ func ListUsers(q model.Query) (model.UserList, error) {
}
for i := range users {
users[i].Password = ""
normalizeUserDefaults(&users[i])
}
return model.UserList{Items: users, Total: int(total)}, nil
}
@@ -129,27 +246,45 @@ func ListUsers(q model.Query) (model.UserList, error) {
func SaveUser(user model.User, password string) (model.User, error) {
user.Username = strings.TrimSpace(user.Username)
if strings.ContainsAny(user.Username, " \t\r\n") {
return user, errors.New("用户名不能包含空格")
return user, safeMessageError{message: "用户名不能包含空格"}
}
if user.Username == "" {
return user, errors.New("用户名不能为空")
return user, safeMessageError{message: "用户名不能为空"}
}
if user.Role == "" || user.Role == model.UserRoleGuest {
user.Role = model.UserRoleUser
}
if user.Status == "" {
user.Status = model.UserStatusActive
}
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
return user, err
} else if ok && saved.ID != user.ID {
return user, errors.New("用户名已存在")
return user, safeMessageError{message: "用户名已存在"}
}
if user.ID == "" {
isCreate := user.ID == ""
if isCreate {
user.ID = newID("user")
user.AffCode = newAffCode()
user.CreatedAt = now()
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
return user, err
} else if ok {
user.CreatedAt = saved.CreatedAt
user.Password = saved.Password
user.AvatarURL = saved.AvatarURL
user.Credits = saved.Credits
user.Extra = saved.Extra
if user.AffCode == "" {
user.AffCode = saved.AffCode
}
if user.AffCode == "" {
user.AffCode = newAffCode()
}
if user.LinuxDoID == "" {
user.LinuxDoID = saved.LinuxDoID
}
user.LastLoginAt = saved.LastLoginAt
}
if password != "" {
hash, err := hashPassword(password)
@@ -158,8 +293,8 @@ func SaveUser(user model.User, password string) (model.User, error) {
}
user.Password = hash
}
if user.Password == "" {
return user, errors.New("密码不能为空")
if isCreate && user.Password == "" {
return user, safeMessageError{message: "密码不能为空"}
}
user.UpdatedAt = now()
user, err := repository.SaveUser(user)
@@ -167,6 +302,103 @@ func SaveUser(user model.User, password string) (model.User, error) {
return user, err
}
func AdjustUserCredits(id string, credits int) (model.User, error) {
user, ok, err := repository.GetUserByID(id)
if err != nil || !ok {
if err != nil {
return user, err
}
return user, safeMessageError{message: "用户不存在"}
}
oldCredits := user.Credits
user.Credits = credits
user.UpdatedAt = now()
user, err = repository.SaveUser(user)
if err == nil && oldCredits != credits {
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: user.ID,
Type: model.CreditLogTypeAdminAdjust,
Amount: credits - oldCredits,
Balance: credits,
Remark: "后台手动调整",
CreatedAt: now(),
})
}
user.Password = ""
return user, err
}
func ConsumeUserCredits(userID string, modelName string, credits int, path string) error {
if credits <= 0 {
return nil
}
user, ok, err := repository.ConsumeUserCredits(userID, credits, now())
if err != nil {
return err
}
if !ok {
return safeMessageError{message: "算力点不足"}
}
extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path})
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: userID,
Type: model.CreditLogTypeAIConsume,
Amount: -credits,
Balance: user.Credits,
Remark: "调用模型 " + modelName,
Extra: string(extra),
CreatedAt: now(),
})
return err
}
func RefundUserCredits(userID string, modelName string, credits int, path string) error {
if credits <= 0 {
return nil
}
user, ok, err := repository.RefundUserCredits(userID, credits, now())
if err != nil {
return err
}
if !ok {
return safeMessageError{message: "用户不存在"}
}
extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path})
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: userID,
Type: model.CreditLogTypeAIRefund,
Amount: credits,
Balance: user.Credits,
Remark: "模型调用失败返还 " + modelName,
Extra: string(extra),
CreatedAt: now(),
})
return err
}
func ListCreditLogs(q model.Query) (model.CreditLogList, error) {
logs, total, err := repository.ListCreditLogs(q)
if err != nil {
return model.CreditLogList{}, err
}
return model.CreditLogList{Items: logs, Total: int(total)}, nil
}
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
if log.ID == "" {
log.ID = newID("credit")
log.CreatedAt = now()
}
return repository.SaveCreditLog(log)
}
func DeleteCreditLog(id string) error {
return repository.DeleteCreditLog(id)
}
func DeleteUser(id string) error {
return repository.DeleteUser(id)
}
@@ -214,6 +446,131 @@ func newID(prefix string) string {
return prefix + "-" + uuid.NewString()
}
func newAffCode() string {
return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", ""))
}
func normalizeUserDefaults(user *model.User) {
if user.Status == "" {
user.Status = model.UserStatusActive
}
if user.AffCode == "" {
user.AffCode = newAffCode()
}
}
type linuxDoTokenResponse struct {
AccessToken string `json:"access_token"`
}
type linuxDoUserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
AvatarTemplate string `json:"avatar_template"`
}
func linuxDoAccessToken(r *http.Request, code string, setting model.PrivateLinuxDoAuthSetting) (string, error) {
values := url.Values{}
values.Set("client_id", setting.ClientID)
values.Set("client_secret", setting.ClientSecret)
values.Set("grant_type", "authorization_code")
values.Set("code", code)
values.Set("redirect_uri", linuxDoRedirectURI(r))
req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var payload linuxDoTokenResponse
if err := doLinuxDoJSON(req, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", safeMessageError{message: "Linux.do 登录失败"}
}
return payload.AccessToken, nil
}
func linuxDoRedirectURI(r *http.Request) string {
return RequestOrigin(r) + "/api/auth/linux-do/callback"
}
func linuxDoProfile(token string) (linuxDoUserResponse, error) {
req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+token)
var payload linuxDoUserResponse
err := doLinuxDoJSON(req, &payload)
return payload, err
}
func doLinuxDoJSON(req *http.Request, payload any) error {
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return safeMessageError{message: "Linux.do 登录失败"}
}
return json.NewDecoder(bytes.NewReader(body)).Decode(payload)
}
func linuxDoUsername(username string, id string) string {
base := strings.TrimSpace(username)
if base == "" {
base = "linuxdo-" + id
}
if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok {
return base
}
return base + "-" + id
}
func linuxDoAvatar(template string) string {
if strings.TrimSpace(template) == "" {
return ""
}
if strings.HasPrefix(template, "//") {
template = "https:" + template
}
if strings.HasPrefix(template, "/") {
template = "https://linux.do" + template
}
return strings.ReplaceAll(template, "{size}", "120")
}
func decodeState(state string) string {
data, err := base64.RawURLEncoding.DecodeString(state)
if err != nil {
return "/"
}
redirect := string(data)
if !strings.HasPrefix(redirect, "/") {
return "/"
}
return redirect
}
func RequestOrigin(r *http.Request) string {
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if host == "" {
host = r.Host
}
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
proto = "http"
}
return proto + "://" + host
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func WarnDefaultSecurityConfig() {
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
+64
View File
@@ -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, "![]("+image+")")
}
return strings.Join(lines, "\n\n")
}
func splitTags(value string, pattern string) []string {
tags := []string{}
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"log"
"sync"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
"github.com/robfig/cron/v3"
)
const defaultPromptSyncCron = "*/5 * * * *"
var (
promptSyncCron *cron.Cron
promptSyncOnce sync.Once
promptSyncMu sync.Mutex
)
func StartPromptSyncScheduler() {
promptSyncOnce.Do(func() {
promptSyncCron = cron.New()
promptSyncCron.Start()
})
RefreshPromptSyncScheduler()
}
func RefreshPromptSyncScheduler() {
promptSyncMu.Lock()
defer promptSyncMu.Unlock()
if promptSyncCron == nil {
return
}
for _, entry := range promptSyncCron.Entries() {
promptSyncCron.Remove(entry.ID)
}
settings, err := repository.GetSettings()
if err != nil {
log.Printf("load prompt sync setting failed err=%v", err)
return
}
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
if setting.Enabled == nil || !*setting.Enabled {
return
}
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err)
}
}
func SyncRemotePromptCategories() {
for _, category := range repository.PromptCategories() {
if !category.Remote {
continue
}
log.Printf("scheduled prompt sync start category=%s", category.Category)
if _, err := SyncPromptCategory(category.Category); err != nil {
log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err)
continue
}
log.Printf("scheduled prompt sync done category=%s", category.Category)
}
}
func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting {
if setting.Cron == "" {
setting.Cron = defaultPromptSyncCron
}
if setting.Enabled == nil {
enabled := true
setting.Enabled = &enabled
}
return setting
}
+7
View File
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
return repository.DeletePrompt(id)
}
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 {
+180 -10
View File
@@ -7,16 +7,20 @@ import (
"io"
"math/rand"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
)
var adminModelHTTPClient = &http.Client{Timeout: 30 * time.Second}
func PublicSettings() (model.PublicSetting, error) {
settings, err := repository.GetSettings()
return normalizePublicSetting(settings.Public), err
return normalizeSettings(settings).Public, err
}
func AdminSettings() (model.Settings, error) {
@@ -31,7 +35,11 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
}
settings = normalizeSettings(settings)
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
result, err := repository.SaveSettings(settings, now())
if err == nil {
RefreshPromptSyncScheduler()
}
return hidePrivateAPIKeys(result), err
}
@@ -48,30 +56,75 @@ func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName str
if err != nil {
return "", err
}
if isArkAgentPlanChannel(resolved) || isSeedanceModelName(modelName) {
return testArkSeedanceChannelModel(resolved, modelName)
}
return testAdminChannelModel(resolved, modelName)
}
func normalizeSettings(settings model.Settings) model.Settings {
settings.Public = normalizePublicSetting(settings.Public)
settings.Private = normalizePrivateSetting(settings.Private)
settings.Public = normalizePublicSettingWithChannels(settings.Public, settings.Private.Channels)
return settings
}
func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting {
return normalizePublicSettingWithChannels(setting, nil)
}
func normalizePublicSettingWithChannels(setting model.PublicSetting, channels []model.ModelChannel) model.PublicSetting {
if setting.ModelChannel.AvailableModels == nil {
setting.ModelChannel.AvailableModels = []string{}
}
if setting.ModelChannel.ModelCosts == nil {
setting.ModelChannel.ModelCosts = []model.ModelCost{}
}
for i := range setting.ModelChannel.ModelCosts {
setting.ModelChannel.ModelCosts[i].Model = strings.TrimSpace(setting.ModelChannel.ModelCosts[i].Model)
if setting.ModelChannel.ModelCosts[i].Credits < 0 {
setting.ModelChannel.ModelCosts[i].Credits = 0
}
}
if setting.ModelChannel.AllowCustomChannel == nil {
enabled := true
setting.ModelChannel.AllowCustomChannel = &enabled
}
if setting.Auth.AllowRegister == nil {
enabled := true
setting.Auth.AllowRegister = &enabled
}
enabledModels := enabledChannelModels(channels)
if len(enabledModels) > 0 {
setting.ModelChannel.AvailableModels = enabledModels
} else {
setting.ModelChannel.AvailableModels = uniqueModelNames(setting.ModelChannel.AvailableModels)
}
setting.ModelChannel.DefaultTextModel = repairDefaultModel(setting.ModelChannel.DefaultTextModel, setting.ModelChannel.AvailableModels, isTextModelName)
setting.ModelChannel.DefaultImageModel = repairDefaultModel(setting.ModelChannel.DefaultImageModel, setting.ModelChannel.AvailableModels, isImageModelName)
setting.ModelChannel.DefaultVideoModel = repairDefaultModel(setting.ModelChannel.DefaultVideoModel, setting.ModelChannel.AvailableModels, isVideoModelName)
setting.ModelChannel.DefaultModel = repairDefaultModel(setting.ModelChannel.DefaultModel, setting.ModelChannel.AvailableModels, isTextModelName)
return setting
}
func ModelCost(modelName string) (int, error) {
settings, err := repository.GetSettings()
if err != nil {
return 0, err
}
modelName = strings.TrimSpace(modelName)
for _, item := range normalizePublicSetting(settings.Public).ModelChannel.ModelCosts {
if item.Model == modelName {
return item.Credits, nil
}
}
return 0, nil
}
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"
@@ -90,6 +143,7 @@ func hidePrivateAPIKeys(settings model.Settings) model.Settings {
for i := range settings.Private.Channels {
settings.Private.Channels[i].APIKey = ""
}
settings.Private.Auth.LinuxDo.ClientSecret = ""
return settings
}
@@ -104,6 +158,12 @@ func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) {
}
}
func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) {
if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" {
settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret
}
}
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
for _, item := range saved {
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
@@ -140,13 +200,101 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
}
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
baseURL := strings.TrimRight(channel.BaseURL, "/")
if !strings.HasSuffix(baseURL, "/v1") {
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
lowerBaseURL := strings.ToLower(baseURL)
if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
baseURL += "/v1"
}
return baseURL + path
}
func normalizeModelChannelBaseURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
if err == nil && parsed.Scheme != "" && parsed.Host != "" {
path := strings.TrimRight(parsed.Path, "/")
lowerPath := strings.ToLower(path)
if index := strings.Index(lowerPath, "/api/plan/v3"); index >= 0 {
end := index + len("/api/plan/v3")
if len(lowerPath) == end || lowerPath[end] == '/' {
parsed.Path = path[:end]
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/")
}
}
}
return baseURL
}
func isArkAgentPlanChannel(channel model.ModelChannel) bool {
baseURL := strings.ToLower(normalizeModelChannelBaseURL(channel.BaseURL))
return strings.HasSuffix(baseURL, "/api/plan/v3")
}
func isSeedanceModelName(modelName string) bool {
modelName = strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance")
}
func enabledChannelModels(channels []model.ModelChannel) []string {
models := []string{}
for _, channel := range channels {
if !channel.Enabled {
continue
}
models = append(models, channel.Models...)
}
return uniqueModelNames(models)
}
func uniqueModelNames(models []string) []string {
result := []string{}
seen := map[string]bool{}
for _, item := range models {
name := strings.TrimSpace(item)
if name == "" || seen[name] {
continue
}
seen[name] = true
result = append(result, name)
}
return result
}
func repairDefaultModel(current string, models []string, preferred func(string) bool) string {
current = strings.TrimSpace(current)
for _, item := range models {
if item == current {
return current
}
}
for _, item := range models {
if preferred(item) {
return item
}
}
if len(models) > 0 {
return models[0]
}
return ""
}
func isVideoModelName(modelName string) bool {
name := strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(name, "seedance") || strings.Contains(name, "video")
}
func isImageModelName(modelName string) bool {
name := strings.ToLower(strings.TrimSpace(modelName))
return strings.Contains(name, "seedream") || strings.Contains(name, "gpt-image") || strings.Contains(name, "image")
}
func isTextModelName(modelName string) bool {
return !isImageModelName(modelName) && !isVideoModelName(modelName)
}
func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
if channel.Protocol == "" {
channel.Protocol = "openai"
@@ -200,13 +348,16 @@ func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
response, err := http.DefaultClient.Do(request)
response, err := adminModelHTTPClient.Do(request)
if err != nil {
return nil, err
return nil, safeMessageError{message: "读取模型失败:上游接口无响应或网络不可达"}
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode >= http.StatusBadRequest {
if response.StatusCode == http.StatusNotFound && isArkAgentPlanChannel(channel) {
return nil, safeMessageError{message: "火山方舟 Agent Plan 未提供 OpenAI /models 模型列表接口,请手动填写模型名称,例如 doubao-seedance-2.0。"}
}
return nil, readAdminChannelError(body, response.StatusCode, "读取模型失败")
}
var payload struct {
@@ -242,9 +393,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
response, err := adminModelHTTPClient.Do(request)
if err != nil {
return "", err
return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"}
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
@@ -265,6 +416,22 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string
return "ok", nil
}
func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) {
if strings.TrimSpace(modelName) == "" {
return "", errors.New("缺少模型名称")
}
if strings.TrimSpace(channel.BaseURL) == "" {
return "", safeMessageError{message: "缺少接口地址"}
}
if strings.TrimSpace(channel.APIKey) == "" {
return "", safeMessageError{message: "缺少 API Key"}
}
if !isArkAgentPlanChannel(channel) {
return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil
}
return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil
}
func readAdminChannelError(body []byte, statusCode int, fallback string) error {
var payload struct {
Error *struct {
@@ -280,8 +447,11 @@ func readAdminChannelError(body []byte, statusCode int, fallback string) error {
return safeMessageError{message: payload.Msg}
}
}
if statusCode == http.StatusUnauthorized {
return safeMessageError{message: "上游接口认证失败(401),请检查 API Key"}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return safeMessageError{message: fmt.Sprintf("上游接口鉴权失败(%d),请检查 API Key、套餐权限或模型权限", statusCode)}
}
if statusCode == http.StatusTooManyRequests {
return safeMessageError{message: "上游接口限流或额度不足(429),请稍后重试或检查额度"}
}
if statusCode > 0 {
return safeMessageError{message: fmt.Sprintf("%s%d", fallback, statusCode)}
+100
View File
@@ -0,0 +1,100 @@
package service
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/basketikun/infinite-canvas/model"
)
func TestFetchAdminChannelModelsParsesOpenAIModels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"z-model"},{"id":"a-model"},{"id":""}]}`))
}))
defer server.Close()
models, err := fetchAdminChannelModels(model.ModelChannel{
BaseURL: server.URL,
APIKey: "test-key",
})
if err != nil {
t.Fatalf("fetchAdminChannelModels returned error: %v", err)
}
if want := []string{"a-model", "z-model"}; !reflect.DeepEqual(models, want) {
t.Fatalf("models = %#v, want %#v", models, want)
}
}
func TestFetchAdminChannelModelsReportsArkPlanModelsUnsupported(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/plan/v3/models" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
http.NotFound(w, r)
}))
defer server.Close()
_, err := fetchAdminChannelModels(model.ModelChannel{
BaseURL: server.URL + "/api/plan/v3/contents/generations/tasks",
APIKey: "test-key",
})
if err == nil {
t.Fatal("expected unsupported /models error")
}
if !strings.Contains(err.Error(), "Agent Plan 未提供 OpenAI /models") {
t.Fatalf("error = %q", err.Error())
}
}
func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) {
got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3/contents/generations/tasks?debug=1"}, "/models")
want := "https://ark.cn-beijing.volces.com/api/plan/v3/models"
if got != want {
t.Fatalf("BuildModelChannelURL = %q, want %q", got, want)
}
}
func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) {
settings := normalizeSettings(model.Settings{
Public: model.PublicSetting{
ModelChannel: model.PublicModelChannelSetting{
AvailableModels: []string{"grok-imagine-video", "disabled-model"},
DefaultModel: "grok-imagine-video",
DefaultTextModel: "missing-text",
DefaultImageModel: "missing-image",
DefaultVideoModel: "missing-video",
},
},
Private: model.PrivateSetting{
Channels: []model.ModelChannel{
{Enabled: true, Models: []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast", "gpt-5.5"}},
{Enabled: false, Models: []string{"disabled-model"}},
},
},
})
channel := settings.Public.ModelChannel
wantModels := []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast"}
if !reflect.DeepEqual(channel.AvailableModels, wantModels) {
t.Fatalf("available models = %#v, want %#v", channel.AvailableModels, wantModels)
}
if channel.DefaultModel != "gpt-5.5" {
t.Fatalf("default model = %q, want text model", channel.DefaultModel)
}
if channel.DefaultTextModel != "gpt-5.5" {
t.Fatalf("default text model = %q, want text model", channel.DefaultTextModel)
}
if channel.DefaultImageModel != "doubao-seedream-5.0-lite" {
t.Fatalf("default image model = %q, want seedream", channel.DefaultImageModel)
}
if channel.DefaultVideoModel != "doubao-seedance-2.0-fast" {
t.Fatalf("default video model = %q, want seedance", channel.DefaultVideoModel)
}
}
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
max_line_length = 255
trim_trailing_whitespace = true
+14
View File
@@ -0,0 +1,14 @@
.next
node_modules
dist
build
coverage
bun.lock
*.log
*.ico
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.svg
+15
View File
@@ -0,0 +1,15 @@
{
"tabWidth": 4,
"useTabs": false,
"printWidth": 255,
"singleQuote": false,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"semi": true,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"embeddedLanguageFormatting": "auto",
"endOfLine": "lf"
}
+13
View File
@@ -16,6 +16,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"file-saver": "^2.0.5",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -32,9 +35,11 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"prettier": "^3.8.3",
"typescript": "^5",
},
},
@@ -552,6 +557,8 @@
"@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="],
"@types/hast": ["@types/hast@2.3.10", "https://registry.npmmirror.com/@types/hast/-/hast-2.3.10.tgz", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"@types/mdast": ["@types/mdast@3.0.15", "https://registry.npmmirror.com/@types/mdast/-/mdast-3.0.15.tgz", { "dependencies": { "@types/unist": "^2" } }, "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ=="],
@@ -800,8 +807,12 @@
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
@@ -1164,6 +1175,8 @@
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+25 -25
View File
@@ -1,27 +1,27 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
+12 -12
View File
@@ -10,17 +10,17 @@ const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim()
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
export default function nextConfig(phase: string): NextConfig {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
const releases = parseChangelog(localChangelog);
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
const releases = parseChangelog(localChangelog);
return {
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
typescript: {
ignoreBuildErrors: true,
},
env: {
NEXT_PUBLIC_APP_VERSION: localVersion,
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
},
};
return {
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
typescript: {
ignoreBuildErrors: true,
},
env: {
NEXT_PUBLIC_APP_VERSION: localVersion,
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
},
};
}
+49 -42
View File
@@ -1,44 +1,51 @@
{
"name": "infinite-canvas",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/nextjs-registry": "^1.3.0",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
"@uiw/react-codemirror": "^4.25.9",
"antd": "^6.4.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.11",
"next": "16.2.3",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"typescript": "^5"
}
"name": "infinite-canvas",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/nextjs-registry": "^1.3.0",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
"@uiw/react-codemirror": "^4.25.9",
"antd": "^6.4.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"file-saver": "^2.0.5",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.11",
"next": "16.2.3",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"prettier": "^3.8.3",
"typescript": "^5"
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

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

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

+9
View File
@@ -0,0 +1,9 @@
<svg class="icon" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="color: rgb(233, 84, 32); width: 20px; height: 20px;">
<g id="linuxdo_icon" data-name="linuxdo_icon">
<path
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill="#EFEFEF"></path>
<path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"></path>
<path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

+244 -131
View File
@@ -12,147 +12,260 @@ import { useAdminAssets } from "./use-admin-assets";
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
const typeOptions = [
{ label: "全部类型", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "全部类型", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
];
const editTypeOptions = typeOptions.slice(1);
export default function AdminAssetsPage() {
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
const tagOptions = tags.map((item) => ({ label: item, value: item }));
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
useEffect(() => {
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
}, [editingAsset, form]);
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
await saveAdminAsset({
...editingAsset,
...value,
type: nextType,
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
});
setEditingAsset(null);
};
useEffect(() => setKeywordText(keyword), [keyword]);
const columns: ProColumns<AdminAsset>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
},
{
title: "类型",
dataIndex: "type",
width: 84,
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "分类",
dataIndex: "category",
width: 120,
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip>
</Space>
),
},
];
const saveAsset = async () => {
const value = await form.validateFields();
const nextType = value.type || "text";
await saveAdminAsset({
...editingAsset,
...value,
type: nextType,
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
tags: (value.tagText || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
});
setEditingAsset(null);
};
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<AdminAsset>
rowKey="id"
columns={columns}
dataSource={assets}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}></Button>]}
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
/>
</Flex>
const columns: ProColumns<AdminAsset>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => (
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
{item.title}
</Typography.Link>
),
},
{
title: "类型",
dataIndex: "type",
width: 84,
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => (
<Space size={[4, 4]} wrap>
{(item.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
),
},
{
title: "分类",
dataIndex: "category",
width: 120,
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
</Tooltip>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} />
</Tooltip>
</Space>
),
},
];
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="category" label="分类"><Input /></Form.Item>
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
</Form>
</Modal>
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}>
{detailAsset ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title>
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(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={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<AdminAsset>
rowKey="id"
columns={columns}
dataSource={assets}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
}}
/>
</Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
</Flex>
) : null}
</Modal>
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</main>
);
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
<Select options={editTypeOptions} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Input />
</Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔">
<Input />
</Form.Item>
<Form.Item name="category" label="分类">
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
{formType === "image" ? (
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
<Input />
</Form.Item>
) : (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={6} />
</Form.Item>
)}
</Form>
</Modal>
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}></Button>}>
{detailAsset ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{detailAsset.title}
</Typography.Title>
<Space wrap>
<Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>
{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}
{(detailAsset.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</Flex>
</Flex>
{detailAsset.description ? (
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
{detailAsset.description}
</Typography.Paragraph>
) : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
</Button>
</Flex>
) : null}
</Modal>
<Modal
title="删除素材"
open={Boolean(deletingAsset)}
onCancel={() => setDeletingAsset(null)}
onOk={async () => {
if (!deletingAsset) return;
await deleteAsset(deletingAsset.id);
setDeletingAsset(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
{deletingAsset?.title}
</Modal>
</main>
);
}
@@ -10,83 +10,83 @@ import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminAssets() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [type, setType] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [type, setType] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const query = useQuery({
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const saveMutation = useMutation({
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
onSuccess: async (_, asset) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success(asset.id ? "素材已保存" : "素材已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminAsset(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
message.success("素材已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}
}, [clearSession, message, query.error, query.isError]);
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}
}, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, type, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setType(queryState.type);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, type, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setType(queryState.type);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const data = query.data;
const data = query.data;
return {
assets: data?.items || [],
tags: data?.tags || [],
keyword,
kind: type,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshAssets: () => query.refetch(),
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
};
return {
assets: data?.items || [],
tags: data?.tags || [],
keyword,
kind: type,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshAssets: () => query.refetch(),
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
};
}
@@ -0,0 +1,221 @@
"use client";
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { Button, Card, Col, Form, Input, InputNumber, Modal, Row, Space, Tag, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import type { AdminCreditLog } from "@/services/api/admin";
import { useAdminCreditLogs } from "./use-admin-credit-logs";
type CreditLogFormValues = Partial<AdminCreditLog>;
const creditLogTypeLabels: Record<string, string> = {
admin_adjust: "后台调整",
ai_consume: "模型消费",
ai_refund: "失败返还",
};
export default function AdminCreditLogsPage() {
const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs();
const [form] = Form.useForm<CreditLogFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingLog, setEditingLog] = useState<Partial<AdminCreditLog> | null>(null);
const [deletingLog, setDeletingLog] = useState<AdminCreditLog | null>(null);
useEffect(() => setKeywordText(keyword), [keyword]);
useEffect(() => {
if (editingLog) form.setFieldsValue({ type: "admin_adjust", amount: 0, balance: 0, ...editingLog });
}, [editingLog, form]);
const saveLog = async () => {
const value = await form.validateFields();
await saveAdminLog({ ...editingLog, ...value });
setEditingLog(null);
};
const columns: ProColumns<AdminCreditLog>[] = [
{
title: "用户 ID",
dataIndex: "userId",
width: 220,
render: (_, item) => <Typography.Text copyable>{item.userId}</Typography.Text>,
},
{
title: "类型",
dataIndex: "type",
width: 140,
render: (_, item) => <Tag>{creditLogTypeLabels[item.type] || item.type || "-"}</Tag>,
},
{
title: "变动",
dataIndex: "amount",
width: 100,
render: (_, item) => <Typography.Text type={item.amount >= 0 ? "success" : "danger"}>{item.amount}</Typography.Text>,
},
{
title: "余额",
dataIndex: "balance",
width: 100,
},
{
title: "备注",
dataIndex: "remark",
ellipsis: true,
render: (_, item) => <Typography.Text type="secondary">{item.remark || "-"}</Typography.Text>,
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 180,
render: (_, item) => <Typography.Text type="secondary">{item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 96,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingLog(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingLog(item)} />
</Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keywordText} placeholder="搜索用户 ID、类型、备注或关联 ID" allowClear enterButton={<SearchOutlined />} onSearch={() => searchLogs(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
</Form.Item>
</Col>
<Col flex="none">
<Form.Item>
<Space>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchLogs(keywordText)}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<AdminCreditLog>
rowKey="id"
columns={columns}
dataSource={logs}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshLogs() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingLog({ type: "admin_adjust", amount: 0, balance: 0 })}>
</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)),
}}
/>
</Space>
<Modal title={editingLog?.id ? "编辑日志" : "新增日志"} open={Boolean(editingLog)} width={680} onCancel={() => setEditingLog(null)} onOk={() => void saveLog()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Row gutter={14}>
<Col span={12}>
<Form.Item name="userId" label="用户 ID" rules={[{ required: true, message: "请输入用户 ID" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请输入类型" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="amount" label="变动数量" rules={[{ required: true, message: "请输入变动数量" }]}>
<InputNumber precision={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="balance" label="变动后余额" rules={[{ required: true, message: "请输入变动后余额" }]}>
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="relatedId" label="关联 ID">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="createdAt" label="创建时间">
<Input placeholder="不填则新增时自动生成" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="extra" label="扩展信息">
<Input.TextArea rows={3} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
title="删除日志"
open={Boolean(deletingLog)}
onCancel={() => setDeletingLog(null)}
onOk={async () => {
if (!deletingLog) return;
await deleteLog(deletingLog.id);
setDeletingLog(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
</Modal>
</main>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { deleteAdminCreditLog, fetchAdminCreditLogs, saveAdminCreditLog, type AdminCreditLog } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminCreditLogs() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "credit-logs", token, keyword, page, pageSize],
queryFn: () => fetchAdminCreditLogs(token, { keyword, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (log: Partial<AdminCreditLog>) => saveAdminCreditLog(token, log),
onSuccess: async (_, log) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
message.success(log.id ? "日志已保存" : "日志已新增");
},
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminCreditLog(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
message.success("日志已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"),
});
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取日志失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}
}, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; page: number; pageSize: number }>) => {
const queryState = { keyword, page, pageSize, ...next };
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const data = query.data;
return {
logs: data?.items || [],
keyword,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
searchLogs: (value = keyword) => updateFilters({ keyword: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
refreshLogs: () => query.refetch(),
saveLog: (log: Partial<AdminCreditLog>) => saveMutation.mutateAsync(log),
deleteLog: (id: string) => deleteMutation.mutateAsync(id),
};
}
+88 -58
View File
@@ -1,6 +1,6 @@
"use client";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons";
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, TransactionOutlined, UserOutlined } from "@ant-design/icons";
import { Button, Flex, Layout, Menu, Typography, theme } from "antd";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -12,68 +12,98 @@ import { adminLayoutStyle } from "@/lib/app-theme";
import { useUserStore } from "@/stores/use-user-store";
const adminMenus = [
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
{ key: "/admin/credit-logs", icon: <TransactionOutlined />, label: "算力点日志" },
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
];
export default function AdminLayout({ children }: { children: ReactNode }) {
const { token: antToken } = theme.useToken();
const router = useRouter();
const pathname = usePathname();
const token = useUserStore((state) => state.token);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
const { token: antToken } = theme.useToken();
const router = useRouter();
const pathname = usePathname();
const token = useUserStore((state) => state.token);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const activeKey = pathname.startsWith("/admin/settings")
? "/admin/settings"
: pathname.startsWith("/admin/assets")
? "/admin/assets"
: pathname.startsWith("/admin/prompts")
? "/admin/prompts"
: pathname.startsWith("/admin/credit-logs")
? "/admin/credit-logs"
: pathname.startsWith("/admin/users")
? "/admin/users"
: "";
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : pathname.startsWith("/admin/credit-logs") ? "算力点日志" : "用户管理";
useEffect(() => {
if (!isReady) return;
if (!token) {
router.replace("/login?redirect=/admin");
return;
}
if (user?.role !== "admin") {
router.replace("/");
}
}, [isReady, router, token, user?.role]);
useEffect(() => {
if (!isReady) return;
if (!token) {
router.replace("/login?redirect=/admin");
return;
}
if (user?.role !== "admin") {
router.replace("/");
}
}, [isReady, router, token, user?.role]);
if (!isReady || !token || user?.role !== "admin") {
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}>
<span />
</div>
);
}
if (!isReady || !token || user?.role !== "admin") {
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}>
<span />
</div>
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
</Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({
...item,
label: (
<Link href={item.key} style={{ color: "inherit" }}>
{item.label}
</Link>
),
style: adminLayoutStyle.menuItem,
}))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
</Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
<Layout.Header
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{pageTitle}
</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions showConfig={false} />
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
}
return (
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}></Typography.Text>
</Flex>
<Menu
mode="inline"
selectedKeys={[activeKey]}
style={adminLayoutStyle.menu}
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer"></Button>
<Button block icon={<LogoutOutlined />} onClick={logout}>退</Button>
</Flex>
</Layout.Sider>
<Layout style={{ background: antToken.colorBgLayout }}>
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}>
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions showConfig={false} />
</Flex>
</Layout.Header>
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
</Layout>
</Layout>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/assets");
redirect("/admin/users");
}
+325 -129
View File
@@ -10,142 +10,338 @@ 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 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 [isSyncOpen, setIsSyncOpen] = useState(false);
const defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
const tagOptions = tags.map((item) => ({ label: item, value: item }));
const {
categories,
prompts,
tags,
keyword,
category,
tag,
page,
pageSize,
total,
isLoading,
isSyncing,
searchPrompts,
changeCategory,
changeTag,
changePage,
changePageSize,
resetFilters,
refreshPrompts,
syncCategory,
savePrompt: saveAdminPrompt,
deletePrompt,
deletePrompts,
} = useAdminPrompts();
const copyText = useCopyText();
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
const [isSyncOpen, setIsSyncOpen] = useState(false);
const defaultCategory = categories[0]?.category || "";
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
const tagOptions = tags.map((item) => ({ label: item, value: item }));
useEffect(() => {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
useEffect(() => {
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
}, [editingPrompt, form]);
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
setEditingPrompt(null);
};
useEffect(() => setKeywordText(keyword), [keyword]);
const columns: ProColumns<Prompt>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
},
{
title: "分类",
dataIndex: "category",
width: 150,
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
</Space>
),
},
];
const savePrompt = async () => {
const value = await form.validateFields();
await saveAdminPrompt({
...editingPrompt,
...value,
category: value.category || defaultCategory,
tags: (value.tagText || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
});
setEditingPrompt(null);
};
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}></Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}></Button></Space></Form.Item></Col>
</Row>
</Form>
</Card>
<ProTable<Prompt>
rowKey="id"
columns={columns}
dataSource={prompts}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={<Space><Typography.Text strong></Typography.Text><Tag>{total} </Tag></Space>}
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}></Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}></Button>]}
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `${value}`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
/>
</Flex>
const batchDeletePrompts = async () => {
await deletePrompts(selectedPromptIds);
setSelectedPromptIds([]);
setIsBatchDeleteOpen(false);
};
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
</Form>
</Modal>
const columns: ProColumns<Prompt>[] = [
{
title: "封面",
dataIndex: "coverUrl",
width: 88,
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
},
{
title: "标题",
dataIndex: "title",
width: 260,
render: (_, item) => (
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
{item.title}
</Typography.Link>
),
},
{
title: "分类",
dataIndex: "category",
width: 150,
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
},
{
title: "标签",
dataIndex: "tags",
width: 180,
render: (_, item) => (
<Space size={[4, 4]} wrap>
{(item.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
),
},
{
title: "操作",
key: "actions",
width: 112,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="详情">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
</Tooltip>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} />
</Tooltip>
</Space>
),
},
];
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}>
{detailPrompt ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title>
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
</Flex>
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search value={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(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={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<Prompt>
rowKey="id"
columns={columns}
dataSource={prompts}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
toolBarRender={() => [
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
</Button>,
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
</Button>,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
}}
/>
</Flex>
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}></Button>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}></Button>}>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
pagination={false}
columns={[
{ title: "远程源", dataIndex: "name", render: (_, item) => <Flex align="center" gap={8}>{item.name}{item.githubUrl ? <Typography.Link href={item.githubUrl} target="_blank"><ExportOutlined /></Typography.Link> : null}</Flex> },
{ title: "", key: "sync", width: 96, align: "right", render: (_, item) => <Button type="primary" loading={isSyncing} onClick={async () => { try { await syncCategory(item.category); setIsSyncOpen(false); } catch {} }}></Button> },
]}
/>
</Modal>
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input />
</Form.Item>
<Form.Item name="category" label="分类">
<Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Input />
</Form.Item>
<Form.Item name="tagText" label="标签,用逗号分隔">
<Input />
</Form.Item>
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
<Input.TextArea rows={6} />
</Form.Item>
</Form>
</Modal>
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingPrompt?.title}
</Modal>
</main>
);
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}></Button>}>
{detailPrompt ? (
<Flex vertical gap={14}>
<Flex gap={14} align="start">
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{detailPrompt.title}
</Typography.Title>
<Space wrap>
<Tag>{categoryName(detailPrompt.category)}</Tag>
{(detailPrompt.tags || []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</Flex>
</Flex>
{detailPrompt.preview ? (
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
{detailPrompt.preview}
</Typography.Paragraph>
) : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
</Button>
{detailPrompt.githubUrl ? (
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
</Button>
) : null}
</Space>
</Flex>
) : null}
</Modal>
<Modal
title="同步远程提示词源"
open={isSyncOpen}
width={640}
onCancel={() => !isSyncing && setIsSyncOpen(false)}
mask={{ closable: !isSyncing }}
footer={
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
</Button>
}
>
<Table
rowKey="category"
dataSource={categories.filter((item) => item.remote)}
pagination={false}
columns={[
{
title: "远程源",
dataIndex: "name",
render: (_, item) => (
<Flex align="center" gap={8}>
{item.name}
{item.githubUrl ? (
<Typography.Link href={item.githubUrl} target="_blank">
<ExportOutlined />
</Typography.Link>
) : null}
</Flex>
),
},
{
title: "",
key: "sync",
width: 96,
align: "right",
render: (_, item) => (
<Button
type="primary"
loading={isSyncing}
onClick={async () => {
try {
await syncCategory(item.category);
setIsSyncOpen(false);
} catch {}
}}
>
</Button>
),
},
]}
/>
</Modal>
<Modal
title="删除提示词"
open={Boolean(deletingPrompt)}
onCancel={() => setDeletingPrompt(null)}
onOk={async () => {
if (!deletingPrompt) return;
await deletePrompt(deletingPrompt.id);
setDeletingPrompt(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
{deletingPrompt?.title}
</Modal>
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{selectedPromptIds.length}
</Modal>
</main>
);
}
@@ -4,117 +4,130 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 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";
const defaultPageSize = 10;
export function useAdminPrompts() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("");
const [tag, setTag] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token),
retry: false,
});
const categoriesQuery = useQuery({
queryKey: ["admin", "prompt-categories", token],
queryFn: () => fetchAdminPromptCategories(token),
enabled: Boolean(token),
retry: false,
});
const promptsQuery = useQuery({
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const promptsQuery = useQuery({
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "同步失败");
},
});
const syncMutation = useMutation({
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
onSuccess: async (categories) => {
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("远程提示词源已同步");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "同步失败");
},
});
const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const saveMutation = useMutation({
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
onSuccess: async (_, prompt) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "保存失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("提示词已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminPrompt(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("提示词已删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "删除失败");
},
});
useEffect(() => {
const error = categoriesQuery.error || promptsQuery.error;
if (!error) return;
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
const batchDeleteMutation = useMutation({
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
message.success("提示词已批量删除");
},
onError: (error) => {
message.error(error instanceof Error ? error.message : "批量删除失败");
},
});
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, category, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setCategory(queryState.category);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
useEffect(() => {
const error = categoriesQuery.error || promptsQuery.error;
if (!error) return;
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
const data = promptsQuery.data;
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
const queryState = { keyword, category, tag, page, pageSize, ...next };
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setCategory(queryState.category);
setTag(queryState.tag);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
return {
categories: categoriesQuery.data || [],
prompts: data?.items || [],
tags: data?.tags || [],
keyword,
category,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending,
isSyncing: syncMutation.isPending,
syncCategory: (category: string) => syncMutation.mutateAsync(category),
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshPrompts: async () => {
await categoriesQuery.refetch();
await promptsQuery.refetch();
},
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
};
const data = promptsQuery.data;
return {
categories: categoriesQuery.data || [],
prompts: data?.items || [],
tags: data?.tags || [],
keyword,
category,
tag,
page,
pageSize,
total: data?.total || 0,
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
isSyncing: syncMutation.isPending,
syncCategory: (category: string) => syncMutation.mutateAsync(category),
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
changeTag: (value: string[]) => updateFilters({ tag: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
refreshPrompts: async () => {
await categoriesQuery.refetch();
await promptsQuery.refetch();
},
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
};
}
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
"use client";
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { Avatar, Button, Card, Col, Divider, Flex, Form, Input, InputNumber, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import type { AdminUser } from "@/services/api/admin";
import { useAdminUsers } from "./use-admin-users";
type UserFormValues = Partial<AdminUser> & { password?: string };
const roleOptions = [
{ label: "普通用户", value: "user" },
{ label: "管理员", value: "admin" },
];
const statusOptions = [
{ label: "正常", value: "active" },
{ label: "禁用", value: "ban" },
];
export default function AdminUsersPage() {
const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, adjustCredits, deleteUser } = useAdminUsers();
const [form] = Form.useForm<UserFormValues>();
const [keywordText, setKeywordText] = useState(keyword);
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null);
useEffect(() => setKeywordText(keyword), [keyword]);
useEffect(() => {
if (editingUser) form.setFieldsValue({ role: "user", status: "active", ...editingUser, password: "" });
}, [editingUser, form]);
const saveUser = async () => {
const value = await form.validateFields();
const userValue = { ...value };
delete userValue.credits;
await saveAdminUser({ ...editingUser, ...userValue, password: value.password || undefined });
setEditingUser(null);
};
const saveCredits = async () => {
if (!editingUser?.id) return;
await adjustCredits(editingUser.id, form.getFieldValue("credits") || 0);
};
const columns: ProColumns<AdminUser>[] = [
{
title: "用户",
dataIndex: "username",
width: 260,
render: (_, item) => (
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
<Avatar src={item.avatarUrl || undefined}>{(item.displayName || item.username || "U").slice(0, 1).toUpperCase()}</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Typography.Text strong ellipsis>
{item.displayName || item.username}
</Typography.Text>
<Typography.Text type="secondary" ellipsis>
{item.username}
</Typography.Text>
</Flex>
</Flex>
),
},
{
title: "角色",
dataIndex: "role",
width: 100,
render: (_, item) => <Tag color={item.role === "admin" ? "gold" : "default"}>{item.role === "admin" ? "管理员" : "用户"}</Tag>,
},
{
title: "状态",
dataIndex: "status",
width: 90,
render: (_, item) => <Tag color={item.status === "ban" ? "red" : "green"}>{item.status === "ban" ? "禁用" : "正常"}</Tag>,
},
{
title: "算力点",
dataIndex: "credits",
width: 100,
render: (_, item) => <Typography.Text>{item.credits}</Typography.Text>,
},
{
title: "Linux.do",
dataIndex: "linuxDoId",
width: 140,
render: (_, item) => <Typography.Text type="secondary">{item.linuxDoId || "-"}</Typography.Text>,
},
{
title: "最近登录",
dataIndex: "lastLoginAt",
width: 180,
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt ? dayjs(item.lastLoginAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
},
{
title: "操作",
key: "actions",
width: 96,
align: "right",
render: (_, item) => (
<Space size={4}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingUser(item)} />
</Tooltip>
<Tooltip title="删除">
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingUser(item)} />
</Tooltip>
</Space>
),
},
];
return (
<main style={{ padding: 24 }}>
<Flex vertical gap={16}>
<Card variant="borderless">
<Form layout="vertical">
<Row gutter={16} align="bottom">
<Col flex="360px">
<Form.Item label="关键词">
<Input.Search
value={keywordText}
placeholder="搜索用户名、昵称、邮箱或 Linux.do ID"
allowClear
enterButton={<SearchOutlined />}
onSearch={() => searchUsers(keywordText)}
onChange={(event) => setKeywordText(event.target.value)}
/>
</Form.Item>
</Col>
<Col flex="none">
<Form.Item>
<Space>
<Button
onClick={() => {
setKeywordText("");
resetFilters();
}}
>
</Button>
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchUsers(keywordText)}>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<ProTable<AdminUser>
rowKey="id"
columns={columns}
dataSource={users}
loading={isLoading}
search={false}
defaultSize="middle"
tableLayout="fixed"
cardProps={{ variant: "borderless" }}
headerTitle={
<Space>
<Typography.Text strong></Typography.Text>
<Tag>{total} </Tag>
</Space>
}
options={{ density: true, setting: true, reload: () => void refreshUsers() }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingUser({ role: "user", status: "active" })}>
</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={editingUser?.id ? "编辑用户" : "新增用户"} open={Boolean(editingUser)} width={680} onCancel={() => setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden>
<Form form={form} layout="vertical" requiredMark={false}>
<Typography.Text strong></Typography.Text>
<Row gutter={14}>
<Col span={12}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="password" label={editingUser?.id ? "新密码" : "密码"} rules={editingUser?.id ? [] : [{ required: true, message: "请输入密码" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="displayName" label="昵称">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="role" label="角色" rules={[{ required: true, message: "请选择角色" }]}>
<Select options={roleOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
<Select options={statusOptions} />
</Form.Item>
</Col>
</Row>
{editingUser?.id ? (
<>
<Divider style={{ margin: "4px 0 16px" }} />
<Typography.Text strong></Typography.Text>
<Row gutter={14}>
<Col span={12}>
<Form.Item label="算力点">
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="credits" noStyle>
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
</Form.Item>
<Button onClick={() => void saveCredits()}></Button>
</Space.Compact>
</Form.Item>
</Col>
</Row>
</>
) : null}
</Form>
</Modal>
<Modal
title="删除用户"
open={Boolean(deletingUser)}
onCancel={() => setDeletingUser(null)}
onOk={async () => {
if (!deletingUser) return;
await deleteUser(deletingUser.id);
setDeletingUser(null);
}}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
{deletingUser?.displayName || deletingUser?.username}
</Modal>
</main>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { adjustAdminUserCredits, deleteAdminUser, fetchAdminUsers, saveAdminUser, type AdminUser } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const defaultPageSize = 10;
export function useAdminUsers() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const token = useUserStore((state) => state.token);
const clearSession = useUserStore((state) => state.clearSession);
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const query = useQuery({
queryKey: ["admin", "users", token, keyword, page, pageSize],
queryFn: () => fetchAdminUsers(token, { keyword, page, pageSize }),
enabled: Boolean(token),
retry: false,
});
const saveMutation = useMutation({
mutationFn: (user: Partial<AdminUser> & { password?: string }) => saveAdminUser(token, user),
onSuccess: async (_, user) => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
message.success(user.id ? "用户已保存" : "用户已新增");
},
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminUser(token, id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
message.success("用户已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"),
});
const creditMutation = useMutation({
mutationFn: ({ id, credits }: { id: string; credits: number }) => adjustAdminUserCredits(token, id, credits),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
message.success("算力点已调整");
},
onError: (error) => message.error(error instanceof Error ? error.message : "调整失败"),
});
useEffect(() => {
if (query.isError) {
const errorMessage = query.error instanceof Error ? query.error.message : "读取用户失败";
message.error(errorMessage);
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
}
}, [clearSession, message, query.error, query.isError]);
const updateFilters = (next: Partial<{ keyword: string; page: number; pageSize: number }>) => {
const queryState = { keyword, page, pageSize, ...next };
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
setKeyword(queryState.keyword);
setPage(queryState.page);
setPageSize(queryState.pageSize);
};
const data = query.data;
return {
users: data?.items || [],
keyword,
page,
pageSize,
total: data?.total || 0,
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending || creditMutation.isPending,
searchUsers: (value = keyword) => updateFilters({ keyword: value }),
changePage: (value: number) => updateFilters({ page: value }),
changePageSize: (value: number) => updateFilters({ pageSize: value }),
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
refreshUsers: () => query.refetch(),
saveUser: (user: Partial<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
adjustCredits: (id: string, credits: number) => creditMutation.mutateAsync({ id, credits }),
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
};
}
+255 -181
View File
@@ -15,200 +15,274 @@ import { uploadImage } from "@/services/image-storage";
const PAGE_SIZE = 12;
export default function AssetLibraryPage() {
const { message } = App.useApp();
const copyText = useCopyText();
const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const { message } = App.useApp();
const copyText = useCopyText();
const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false,
});
const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false,
});
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
);
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input
size="large"
className="w-full"
prefix={<Search className="size-4 text-stone-400" />}
value={keyword}
placeholder="按标题查询"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
/>
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag
key={item.value || "all"}
checked={selectedType === item.value}
className={cn("prompt-filter-tag", selectedType === item.value && "is-active")}
onChange={() => {
setPage(1);
setSelectedType(item.value);
}}
>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag
checked={selectedTags.length === 0}
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
onChange={() => {
setPage(1);
setSelectedTags([]);
}}
>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
onChange={() => {
setPage(1);
toggleTag(tag);
}}
>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? (
<Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" />
) : (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>
)}
<div>
<Typography.Title level={4} className="!mb-2">
{selectedAsset.title}
</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs">
</Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
</Button>
) : null}
{selectedAsset.type === "image" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
</Button>
) : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
</Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}></Button> : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
}
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
const cover = asset.coverUrl;
return (
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}></Button>
</div>
</Card>
);
const cover = asset.coverUrl;
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
{cover ? (
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>
)}
</button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
</Button>
</div>
</Card>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return await blobToDataUrl(blob);
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return await blobToDataUrl(blob);
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
@@ -0,0 +1,73 @@
import { saveAs } from "file-saver";
import { createZip, readZip } from "@/lib/zip";
import { getMediaBlob, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, setImageBlob } from "@/services/image-storage";
import type { Asset } from "@/stores/use-asset-store";
type AssetExportFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
assets: Asset[];
files: AssetExportItem[];
};
type AssetExportItem = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
export async function exportAssets(assets: Asset[]) {
const files: AssetExportItem[] = [];
const zipFiles: { name: string; data: BlobPart }[] = [];
await Promise.all(
assets.map(async (asset) => {
if (asset.kind !== "image" && asset.kind !== "video") return;
const storageKey = asset.data.storageKey;
if (!storageKey) return;
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`;
files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, "我的素材.zip");
}
export async function readAssetPackage(file: File) {
const zip = await readZip(file);
const assetFile = zip.get("assets.json");
if (!assetFile) throw new Error("missing assets.json");
const data = JSON.parse(await assetFile.text()) as AssetExportFile;
await Promise.all(
data.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
);
return data.assets;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, kind: Asset["kind"]) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
return kind === "image" ? "png" : "bin";
}
+486 -326
View File
@@ -3,381 +3,541 @@
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
import { saveAs } from "file-saver";
import { useCopyText } from "@/hooks/use-copy-text";
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { exportAssets, readAssetPackage } from "./asset-transfer";
type AssetFormValues = {
kind: AssetKind;
title: string;
coverUrl: string;
tags: string[];
source?: string;
note?: string;
content?: string;
kind: AssetKind;
title: string;
coverUrl: string;
tags: string[];
source?: string;
note?: string;
content?: string;
};
type ImageDraft = ImageAsset["data"] | null;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
];
export default function AssetsPage() {
const { message } = App.useApp();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
const coverUrl = Form.useWatch("coverUrl", form) || "";
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]);
const { message } = App.useApp();
const copyText = useCopyText();
const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const assetInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
const coverUrl = Form.useWatch("coverUrl", form) || "";
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image" || asset.kind === "video"), [assets]);
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
return validAssets.filter((asset) => {
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
if (!query) return true;
return assetSearchText(asset).includes(query);
});
}, [validAssets, keyword, kindFilter]);
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
return validAssets.filter((asset) => {
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
if (!query) return true;
return assetSearchText(asset).includes(query);
});
}, [validAssets, keyword, kindFilter]);
const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredAssets.slice(start, start + pageSize);
}, [filteredAssets, page, pageSize]);
const visibleAssets = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredAssets.slice(start, start + pageSize);
}, [filteredAssets, page, pageSize]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
setPage((value) => Math.min(value, maxPage));
}, [filteredAssets.length, pageSize]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
setPage((value) => Math.min(value, maxPage));
}, [filteredAssets.length, pageSize]);
const openCreate = () => {
setEditingAsset(null);
setImageDraft(null);
setFormKind("text");
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
setIsAssetOpen(true);
};
const openEdit = (asset: Asset) => {
setEditingAsset(asset);
setFormKind(asset.kind);
setImageDraft(asset.kind === "image" ? asset.data : null);
form.setFieldsValue({
kind: asset.kind,
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags || [],
source: asset.source,
note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
});
setIsAssetOpen(true);
};
const saveAsset = async () => {
const values = await form.validateFields();
const base = {
title: values.title.trim(),
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
metadata: editingAsset?.metadata || { source: "manual" },
const openCreate = () => {
setEditingAsset(null);
setImageDraft(null);
setFormKind("text");
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
setIsAssetOpen(true);
};
if (values.kind === "text") {
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
} else {
if (!imageDraft) {
message.error("请选择图片文件");
return;
}
const asset = { ...base, kind: "image" as const, data: imageDraft };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
}
const openEdit = (asset: Asset) => {
setEditingAsset(asset);
setFormKind(asset.kind);
setImageDraft(asset.kind === "image" ? asset.data : null);
form.setFieldsValue({
kind: asset.kind,
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags || [],
source: asset.source,
note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
});
setIsAssetOpen(true);
};
message.success(editingAsset ? "素材已更新" : "素材已保存");
setIsAssetOpen(false);
};
const saveAsset = async () => {
const values = await form.validateFields();
const base = {
title: values.title.trim(),
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
metadata: editingAsset?.metadata || { source: "manual" },
};
const readCoverFile = async (file?: File) => {
if (!file) return;
const dataUrl = await readFileAsDataUrl(file);
form.setFieldValue("coverUrl", dataUrl);
};
if (values.kind === "text") {
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
} else {
if (!imageDraft) {
message.error("请选择图片文件");
return;
}
const asset = { ...base, kind: "image" as const, data: imageDraft };
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
}
const readImageFile = async (file?: File) => {
if (!file || !file.type.startsWith("image/")) return;
const image = await uploadImage(file);
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
setImageDraft(draft);
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
};
message.success(editingAsset ? "素材已更新" : "素材已保存");
setIsAssetOpen(false);
};
const copyAssetText = async (asset: Asset) => {
if (asset.kind !== "text") return;
copyText(asset.data.content, "文本已复制");
};
const readCoverFile = async (file?: File) => {
if (!file) return;
const dataUrl = await readFileAsDataUrl(file);
form.setFieldValue("coverUrl", dataUrl);
};
const downloadImage = (asset: Asset) => {
if (asset.kind !== "image") return;
const link = document.createElement("a");
link.href = asset.data.dataUrl;
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
link.click();
};
const readImageFile = async (file?: File) => {
if (!file || !file.type.startsWith("image/")) return;
const image = await uploadImage(file);
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
setImageDraft(draft);
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
};
const confirmDelete = () => {
if (!deletingAsset) return;
removeAsset(deletingAsset.id);
message.success("素材已删除");
setDeletingAsset(null);
};
const copyAssetText = async (asset: Asset) => {
if (asset.kind !== "text") return;
copyText(asset.data.content, "文本已复制");
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p>
</div>
const downloadImage = (asset: Asset) => {
if (asset.kind !== "image" && asset.kind !== "video") return;
saveAs(asset.kind === "video" ? asset.data.url : asset.data.dataUrl, `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`);
};
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input.Search className="w-full" size="large" allowClear prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} />
</div>
const exportAllAssets = async () => {
if (!validAssets.length) {
message.warning("暂无素材可导出");
return;
}
await exportAssets(validAssets);
};
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{kindOptions.map((option) => (
<Tag.CheckableTag key={option.value} checked={kindFilter === option.value} className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")} onChange={() => { setPage(1); setKindFilter(option.value as AssetKind | "all"); }}>
{option.label}
</Tag.CheckableTag>
))}
const importAssetZip = async (file?: File) => {
if (!file) return;
try {
const importedAssets = await readAssetPackage(file);
importedAssets.forEach((asset) => {
const payload = { ...asset } as Record<string, unknown>;
delete payload.id;
delete payload.createdAt;
delete payload.updatedAt;
addAsset(payload as Parameters<typeof addAsset>[0]);
});
message.success(`已导入 ${importedAssets.length} 个素材`);
} catch {
message.error("导入失败,请选择有效的素材压缩包");
} finally {
if (assetInputRef.current) assetInputRef.current.value = "";
}
};
const confirmDelete = () => {
if (!deletingAsset) return;
removeAsset(deletingAsset.id);
message.success("素材已删除");
setDeletingAsset(null);
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400"></p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input.Search
className="w-full"
size="large"
allowClear
prefix={<Search className="size-4 text-stone-400" />}
value={keyword}
placeholder="搜索标题、内容、标签或来源"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
onSearch={(value) => {
setPage(1);
setKeyword(value);
}}
/>
</div>
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
<div className="text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{kindOptions.map((option) => (
<Tag.CheckableTag
key={option.value}
checked={kindFilter === option.value}
className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(option.value as AssetKind | "all");
}}
>
{option.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="flex flex-wrap gap-4">
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => void exportAllAssets()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => assetInputRef.current?.click()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={openCreate}
>
</button>
</div>
</div>
</div>
</div>
</div>
<button type="button" className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300" onClick={openCreate}>
</button>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyAssetText}
onDownload={downloadImage}
onDelete={() => setDeletingAsset(asset)}
/>
))}
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
))}
</div>
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination
current={page}
pageSize={pageSize}
total={filteredAssets.length}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
</div>
</div>
</main>
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型">
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}></Button>
</Space.Compact>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 提示词库" />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="可选" />
</Form.Item>
</div>
{formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
</Form.Item>
) : (
<Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}></Button>
{imageDraft ? <Typography.Text type="secondary" className="ml-3 text-xs">{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}</Typography.Text> : <Typography.Text type="secondary" className="ml-3 text-xs"></Typography.Text>}
<div className="flex justify-center">
<Pagination
current={page}
pageSize={pageSize}
total={filteredAssets.length}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
</div>
</div>
</Form.Item>
)}
</Form>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
<Typography.Text strong></Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
{coverUrl || imageDraft?.dataUrl ? <img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>}
<div className="p-4">
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.length ? tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>) : <Tag className="m-0"></Tag>}
</main>
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
<Form.Item name="kind" label="类型">
<Select
options={[
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
]}
onChange={(value) => setFormKind(value)}
/>
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input size="large" placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ""]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 提示词库" />
</Form.Item>
<Form.Item name="note" label="备注">
<Input placeholder="可选" />
</Form.Item>
</div>
{formKind === "text" ? (
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
</Form.Item>
) : (
<Form.Item label="图片内容" required>
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
</Button>
{imageDraft ? (
<Typography.Text type="secondary" className="ml-3 text-xs">
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}
</Typography.Text>
) : (
<Typography.Text type="secondary" className="ml-3 text-xs">
</Typography.Text>
)}
</div>
</Form.Item>
)}
</Form>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
<Typography.Text strong></Typography.Text>
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
{coverUrl || imageDraft?.dataUrl ? (
<img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
)}
<div className="p-4">
<Typography.Text strong ellipsis className="block">
{title || "未命名素材"}
</Typography.Text>
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.length ? (
tags.map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))
) : (
<Tag className="m-0"></Tag>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readCoverFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void readImageFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</div>
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
void readCoverFile(event.target.files?.[0]);
event.target.value = "";
}} />
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
void readImageFile(event.target.files?.[0]);
event.target.value = "";
}} />
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
</div>
);
);
}
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
const summary = assetSummary(asset);
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
</button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Typography.Text type="secondary" className="mt-1 block text-xs">{asset.source || "未标注来源"}</Typography.Text>
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
const summary = assetSummary(asset);
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
{cover ? (
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
)}
</button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Typography.Text type="secondary" className="mt-1 block text-xs">
{asset.source || "未标注来源"}
</Typography.Text>
</div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{(asset.tags || []).slice(0, 3).map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}>
</Button>
{asset.kind !== "video" ? (
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>
</Button>
) : null}
{asset.kind === "text" ? (
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>
</Button>
) : null}
{asset.kind === "image" || asset.kind === "video" ? (
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>
</Button>
) : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
</Button>
</div>
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{summary}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{(asset.tags || []).slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
{!asset.tags?.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}></Button>
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}></Button>
{asset.kind === "text" ? <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}></Button> : null}
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}></Button> : null}
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}></Button>
</div>
</Card>
);
</Card>
);
}
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
return (
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (
<div className="space-y-5">
{cover ? <Image src={cover} alt={asset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
<div>
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title>
<Space size={[4, 4]} wrap>
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}
</Space>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs"></Typography.Text>
{asset.kind === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>}
</div>
{asset.note ? <div><Typography.Text type="secondary"></Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null}
<Space>
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}></Button> : null}
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}></Button> : null}
</Space>
</div>
) : null}
</Drawer>
);
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
return (
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (
<div className="space-y-5">
{cover ? (
<Image src={cover} alt={asset.title} className="rounded-lg" />
) : (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
)}
<div>
<Typography.Title level={4} className="!mb-2">
{asset.title}
</Typography.Title>
<Space size={[4, 4]} wrap>
<Tag>{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</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>
) : asset.kind === "video" ? (
<video src={asset.data.url} controls className="mt-2 aspect-video w-full rounded-lg bg-black" />
) : (
<Typography.Text className="mt-2 block">
{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}
</Typography.Text>
)}
</div>
{asset.note ? (
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
</div>
) : null}
<Space>
{asset.kind === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
</Button>
) : null}
{asset.kind === "image" || asset.kind === "video" ? (
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
{asset.kind === "video" ? "下载视频" : "下载图片"}
</Button>
) : null}
</Space>
</div>
) : null}
</Drawer>
);
}
function assetSummary(asset: Asset) {
if (asset.kind === "text") return asset.data.content;
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
if (asset.kind === "text") return asset.data.content;
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
}
function assetSearchText(asset: Asset) {
return [
asset.title,
asset.source || "",
asset.note || "",
(asset.tags || []).join(" "),
asset.kind === "text" ? asset.data.content : asset.data.mimeType,
].join(" ").toLowerCase();
return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
import CanvasClientPage from "./canvas-client-page";
export default function CanvasPage() {
return <CanvasClientPage />;
return <CanvasClientPage />;
}
@@ -12,254 +12,252 @@ 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 } | { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number };
type Props = {
open: boolean;
defaultTab?: AssetPickerTab;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
open: boolean;
defaultTab?: AssetPickerTab;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
};
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
useEffect(() => {
if (open) setActiveTab(defaultTab);
}, [open, defaultTab]);
useEffect(() => {
if (open) setActiveTab(defaultTab);
}, [open, defaultTab]);
return (
<Modal
title="选择素材"
open={open}
onCancel={onClose}
footer={null}
width={860}
destroyOnHidden
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as AssetPickerTab)}
items={[
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
]}
/>
</Modal>
);
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as AssetPickerTab)}
items={[
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
]}
/>
</Modal>
);
}
const PAGE_SIZE = 8;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
];
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [page, setPage] = useState(1);
const [inserting, setInserting] = useState<string | null>(null);
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [page, setPage] = useState(1);
const [inserting, setInserting] = useState<string | null>(null);
const query = useQuery({
queryKey: ["asset-picker-library", keyword, kindFilter, page],
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
retry: false,
});
const query = useQuery({
queryKey: ["asset-picker-library", keyword, kindFilter, page],
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
retry: false,
});
const items = query.data?.items || [];
const total = query.data?.total || 0;
const items = query.data?.items || [];
const total = query.data?.total || 0;
const handleInsert = async (asset: AssetLibraryItem) => {
try {
setInserting(asset.id);
if (asset.type === "text") {
onInsert({ kind: "text", content: asset.content, title: asset.title });
} else {
const dataUrl = await remoteImageToDataUrl(asset.url);
onInsert({ kind: "image", dataUrl, title: asset.title });
}
} catch {
message.error("插入失败");
} finally {
setInserting(null);
}
};
const handleInsert = async (asset: AssetLibraryItem) => {
try {
setInserting(asset.id);
if (asset.type === "text") {
onInsert({ kind: "text", content: asset.content, title: asset.title });
} else {
const dataUrl = await remoteImageToDataUrl(asset.url);
onInsert({ kind: "image", dataUrl, title: asset.title });
}
} catch {
message.error("插入失败");
} finally {
setInserting(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
/>
<div className="flex gap-1.5">
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
<Tag.CheckableTag
key={opt.value || "all"}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }}
>
{opt.label}
</Tag.CheckableTag>
))}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
<div className="flex gap-1.5">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((opt) => (
<Tag.CheckableTag
key={opt.value || "all"}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{query.isLoading ? (
<div className="flex justify-center py-16">
<Spin />
</div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
</div>
{query.isLoading ? (
<div className="flex justify-center py-16"><Spin /></div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard
key={asset.id}
title={asset.title}
kind={asset.type}
cover={asset.coverUrl}
loading={inserting === asset.id}
onClick={() => void handleInsert(asset)}
/>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
);
}
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
return (
<button
type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick}
disabled={loading}
>
{cover ? (
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{title}
</div>
)}
<div className="p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag>
</div>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
<Spin size="small" />
</div>
)}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">
</div>
</button>
);
return (
<button
type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick}
disabled={loading}
>
{cover ? (
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">{title}</div>
)}
<div className="p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
</div>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
<Spin size="small" />
</div>
)}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
</button>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
const [page, setPage] = useState(1);
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets
.filter((a) => a.kind === "text" || a.kind === "image")
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
}, [assets, keyword, kindFilter]);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets
.filter((a) => a.kind === "text" || a.kind === "image" || a.kind === "video")
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
}, [assets, keyword, kindFilter]);
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
setPage((v) => Math.min(v, maxPage));
}, [filtered.length]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
setPage((v) => Math.min(v, maxPage));
}, [filtered.length]);
const handleInsert = (asset: Asset) => {
if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
} else {
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
}
};
const handleInsert = (asset: Asset) => {
if (asset.kind === "text") {
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
} else {
onInsert(asset.kind === "video" ? { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height } : { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
/>
<div className="flex gap-1.5">
{kindOptions.map((opt) => (
<Tag.CheckableTag
key={opt.value}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => { setPage(1); setKindFilter(opt.value); }}
>
{opt.label}
</Tag.CheckableTag>
))}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
<div className="flex gap-1.5">
{kindOptions.map((opt) => (
<Tag.CheckableTag
key={opt.value}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
</div>
{visible.length ? (
<div className="grid grid-cols-4 gap-3">
{visible.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
);
}
File diff suppressed because it is too large Load Diff
@@ -2,253 +2,416 @@
import type { CSSProperties } from "react";
import { useState } from "react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play } from "lucide-react";
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasSizePicker } from "./canvas-size-picker";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number };
inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void;
onGenerate: (nodeId: string) => void;
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void;
onGenerate: (nodeId: string) => void;
};
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
const { message } = App.useApp();
const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const globalConfig = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image");
const { message } = App.useApp();
const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image");
const videoInputs = inputs.filter((input) => input.type === "video");
const audioInputs = inputs.filter((input) => input.type === "audio");
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
const targetInput = sameTypeInputs[sameTypeIndex + offset];
if (!targetInput) return;
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
const next = [...inputs];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
message.success("已调整输入顺序");
};
const startTextEdit = (input: NodeGenerationInput) => {
setEditingTextId(input.nodeId);
setEditingText(input.text || "");
};
const saveTextEdit = () => {
if (!editingTextId) return;
onTextInputChange(editingTextId, editingText);
setEditingText("");
setEditingTextId(null);
message.success("已保存文本提示词");
};
const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
const targetInput = sameTypeInputs[sameTypeIndex + offset];
if (!targetInput) return;
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
const next = [...inputs];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
message.success("已调整输入顺序");
};
const startTextEdit = (input: NodeGenerationInput) => {
setEditingTextId(input.nodeId);
setEditingText(input.text || "");
};
const saveTextEdit = () => {
if (!editingTextId) return;
onTextInputChange(editingTextId, editingText);
setEditingText("");
setEditingTextId(null);
message.success("已保存文本提示词");
};
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div>
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<Segmented
size="small"
className="canvas-config-mode !rounded-md !p-0.5"
value={mode}
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
options={[
{ value: "image", label: <span className="inline-flex items-center gap-1"><ImageIcon className="size-3.5" /></span> },
{ value: "text", label: <span className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" /></span> },
]}
/>
</div>
</div>
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<button
type="button"
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]"
style={chipStyle}
onClick={() => setPreviewOpen(true)}
>
<Eye className="size-3.5" />
</button>
</div>
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div>
)}
>
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
{inputs.length ? (
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => <ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />)}
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div>
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<Segmented
size="small"
className="canvas-config-mode !rounded-md !p-0.5"
value={mode}
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
options={[
{
value: "image",
label: (
<span className="inline-flex items-center gap-1">
<ImageIcon className="size-3.5" />
</span>
),
},
{
value: "text",
label: (
<span className="inline-flex items-center gap-1">
<MessageSquare className="size-3.5" />
</span>
),
},
{
value: "video",
label: (
<span className="inline-flex items-center gap-1">
<Video className="size-3.5" />
</span>
),
},
]}
/>
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5">
{textInputs.map((input, index) => <TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />)}
</div>
</PreviewSection>
</div>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{editingTextId ? (
<>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}></Button>
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount}`} style={chipStyle} />
<InputChip label="参考音频" value={`${inputSummary.audioCount}`} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
<Eye className="size-3.5" />
</button>
</div>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : null}
</div>
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount && !inputSummary.audioCount)}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
>
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1">
<CreditSymbol />
{credits.toLocaleString()}
</span>
{isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
<span></span>
</span>
</Button>
<Modal
title="输入预览"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
centered
width={860}
mask={{ closable: true }}
keyboard
destroyOnHidden
modalRender={(modal) => (
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{modal}
</div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}></Button>
<Button size="small" type="primary" onClick={saveTextEdit}></Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)}
</div>
</div>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
)}
>
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
{inputs.length ? (
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
<div className="shrink-0">
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{imageInputs.map((input, index) => (
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="shrink-0">
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{videoInputs.map((input, index) => (
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="shrink-0">
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{audioInputs.map((input, index) => (
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
<div className="space-y-1.5">
{textInputs.map((input, index) => (
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
))}
</div>
</PreviewSection>
</div>
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
{editingTextId ? (
<>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
</Button>
</div>
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
<div className="mt-2 flex justify-end gap-2">
<Button size="small" onClick={() => setEditingTextId(null)}>
</Button>
<Button size="small" type="primary" onClick={saveTextEdit}>
</Button>
</div>
</>
) : (
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
<Edit3 className="mx-auto mb-2 size-5" />
</div>
)}
</div>
</div>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
)}
</div>
</Modal>
</div>
</Modal>
</div>
);
);
}
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
return (
<section>
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
<div className="text-xs font-semibold">{title}</div>
<div className="text-[11px] opacity-50">{count} </div>
</div>
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
</section>
);
return (
<section>
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
<div className="text-xs font-semibold">{title}</div>
<div className="text-[11px] opacity-50">{count} </div>
</div>
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
</section>
);
}
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
<div className="min-w-0">
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div>
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
</div>
<div className="flex justify-end gap-1">
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
function TextSortCard({
input,
textIndex,
textTotal,
inputs,
theme,
onMove,
onEdit,
}: {
input: NodeGenerationInput;
textIndex: number;
textTotal: number;
inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
onEdit: (input: NodeGenerationInput) => void;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
<div className="min-w-0">
<div className="truncate text-[10px] font-medium opacity-50"> {textIndex + 1}</div>
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
</div>
<div className="flex justify-end gap-1">
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) {
if (!input.image) return null;
return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageIndex + 1}</span>
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
function ImageSortCard({
input,
imageIndex,
imageTotal,
inputs,
theme,
onMove,
}: {
input: NodeGenerationInput;
imageIndex: number;
imageTotal: number;
inputs: NodeGenerationInput[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.image) return null;
return (
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageReferenceLabel(imageIndex)}</span>
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function VideoSortCard({
input,
videoIndex,
videoTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
videoIndex: number;
videoTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.video) return null;
return (
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{seedanceReferenceLabel("video", videoIndex)}</span>
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function AudioSortCard({
input,
audioIndex,
audioTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
audioIndex: number;
audioTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.audio) return null;
return (
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
<Music2 className="size-3.5 shrink-0" />
<span className="truncate">{input.title}</span>
</div>
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
<div className="mt-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
<span className="text-[10px] opacity-45">{seedanceReferenceLabel("audio", audioIndex)}</span>
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
</div>
</div>
);
}
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<>
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</>
);
return (
<>
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</>
);
}
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return (
<div className="absolute inset-x-1 bottom-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</div>
);
return (
<div className="absolute inset-x-1 bottom-1 flex justify-between">
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
</div>
);
}
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
return (
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
<span>{label}</span>
<span className="font-medium">{value}</span>
</div>
);
return (
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
<span>{label}</span>
<span className="font-medium">{value}</span>
</div>
);
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
}
function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value };
if (key === "videoWatermark") return { watermark: value };
return { [key]: value };
}
@@ -2,72 +2,52 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
export function ConnectionPath({
connection,
from,
to,
active,
onSelect,
}: {
connection: CanvasConnection;
from: CanvasNodeData;
to: CanvasNodeData;
active: boolean;
onSelect: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startX = from.position.x + from.width;
const startY = from.position.y + from.height / 2;
const endX = to.position.x;
const endY = to.position.y + to.height / 2;
const dx = Math.abs(endX - startX);
const curvature = Math.max(dx * 0.5, 50);
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startX = from.position.x + from.width;
const startY = from.position.y + from.height / 2;
const endX = to.position.x;
const endY = to.position.y + to.height / 2;
const dx = Math.abs(endX - startX);
const curvature = Math.max(dx * 0.5, 50);
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
return (
<g>
<path
data-connection-id={connection.id}
d={pathD}
stroke="transparent"
strokeWidth="16"
fill="none"
style={{ cursor: "pointer", pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
/>
<path
d={pathD}
stroke={active ? theme.node.activeStroke : theme.node.muted}
strokeWidth={active ? 3 : 2}
strokeOpacity={active ? 1 : 0.82}
fill="none"
style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }}
/>
</g>
);
return (
<g>
<path
data-connection-id={connection.id}
d={pathD}
stroke="transparent"
strokeWidth="16"
fill="none"
style={{ cursor: "pointer", pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
/>
<path
d={pathD}
stroke={active ? theme.node.activeStroke : theme.node.muted}
strokeWidth={active ? 3 : 2}
strokeOpacity={active ? 1 : 0.82}
fill="none"
style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }}
/>
</g>
);
}
export function ActiveConnectionPath({
node,
handle,
mouseWorld,
}: {
node?: CanvasNodeData;
handle: ConnectionHandle;
mouseWorld: Position;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!node) return null;
export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!node) return null;
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
const distance = Math.abs(endX - startX);
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
const distance = Math.abs(endX - startX);
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
}
@@ -8,63 +8,38 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "../types";
export function CanvasNodeContextMenu({
menu,
onClose,
onDuplicate,
onDelete,
}: {
menu: ContextMenuState;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => {
const close = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Element && target.closest(".ant-popover")) return;
onClose();
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, [onClose]);
useEffect(() => {
const close = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Element && target.closest(".ant-popover")) return;
onClose();
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, [onClose]);
return (
<div
className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl"
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
</div>
);
return (
<div
className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl"
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
</div>
);
}
function MenuButton({
icon,
label,
onClick,
danger = false,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
function MenuButton({ icon, label, onClick, danger = false }: { icon: ReactNode; label: string; onClick?: () => void; danger?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80"
style={{ color: danger ? "#f87171" : theme.node.text }}
onClick={onClick}
>
{icon}
<span>{label}</span>
</button>
);
return (
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80" style={{ color: danger ? "#f87171" : theme.node.text }} onClick={onClick}>
{icon}
<span>{label}</span>
</button>
);
}
@@ -7,27 +7,34 @@ import { useCanvasStore } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() {
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const confirm = () => {
deleteProjects(ids);
cleanupImages();
removeSelectedIds(ids);
setDeleteIds([]);
};
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const confirm = () => {
deleteProjects(ids);
cleanupImages();
removeSelectedIds(ids);
setDeleteIds([]);
};
return (
<Modal
title="删除画布?"
open={ids.length > 0}
centered
onCancel={() => setDeleteIds([])}
footer={<><Button onClick={() => setDeleteIds([])}></Button><Button danger type="primary" onClick={confirm}></Button></>}
>
<p className="text-sm text-stone-500"> {ids.length} 线</p>
</Modal>
);
return (
<Modal
title="删除画布?"
open={ids.length > 0}
centered
onCancel={() => setDeleteIds([])}
footer={
<>
<Button onClick={() => setDeleteIds([])}></Button>
<Button danger type="primary" onClick={confirm}>
</Button>
</>
}
>
<p className="text-sm text-stone-500"> {ids.length} 线</p>
</Modal>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
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";
autoAdjustOverflow?: boolean;
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
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 updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
};
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
onOpenChange?.(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [onOpenChange, open]);
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<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" />} onClick={() => updateOpen(!open)}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
</span>
</Button>
</span>
{panel}
</>
);
}
function ImageSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasImageSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
}
@@ -6,149 +6,133 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
export function Minimap({
nodes,
viewport,
viewportSize,
onViewportChange,
}: {
nodes: CanvasNodeData[];
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onViewportChange: (viewport: ViewportTransform) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const width = 240;
const height = 160;
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const width = 240;
const height = 160;
const { worldBounds, scale, offset } = useMemo(() => {
if (!nodes.length) {
return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } };
}
const { worldBounds, scale, offset } = useMemo(() => {
if (!nodes.length) {
return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + node.width);
maxY = Math.max(maxY, node.position.y + node.height);
});
nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + node.width);
maxY = Math.max(maxY, node.position.y + node.height);
});
minX -= 500;
minY -= 500;
maxX += 500;
maxY += 500;
minX -= 500;
minY -= 500;
maxX += 500;
maxY += 500;
const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY;
const nextScale = Math.min(width / boundsWidth, height / boundsHeight);
const mapContentW = boundsWidth * nextScale;
const mapContentH = boundsHeight * nextScale;
const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY;
const nextScale = Math.min(width / boundsWidth, height / boundsHeight);
const mapContentW = boundsWidth * nextScale;
const mapContentH = boundsHeight * nextScale;
return {
worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight },
scale: nextScale,
offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 },
return {
worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight },
scale: nextScale,
offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 },
};
}, [nodes]);
const toMinimap = useCallback(
(worldX: number, worldY: number) => {
return {
x: (worldX - worldBounds.x) * scale + offset.x,
y: (worldY - worldBounds.y) * scale + offset.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const toWorld = useCallback(
(minimapX: number, minimapY: number) => {
return {
x: (minimapX - offset.x) / scale + worldBounds.x,
y: (minimapY - offset.y) / scale + worldBounds.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const viewportRect = useMemo(() => {
const vx = -viewport.x / viewport.k;
const vy = -viewport.y / viewport.k;
const vw = viewportSize.width / viewport.k;
const vh = viewportSize.height / viewport.k;
const p1 = toMinimap(vx, vy);
const p2 = toMinimap(vx + vw, vy + vh);
return {
x: p1.x,
y: p1.y,
w: Math.max(p2.x - p1.x, 4),
h: Math.max(p2.y - p1.y, 4),
};
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const updateViewportFromEvent = (event: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
onViewportChange({
x: viewportSize.width / 2 - world.x * viewport.k,
y: viewportSize.height / 2 - world.y * viewport.k,
k: viewport.k,
});
};
}, [nodes]);
const toMinimap = useCallback(
(worldX: number, worldY: number) => {
return {
x: (worldX - worldBounds.x) * scale + offset.x,
y: (worldY - worldBounds.y) * scale + offset.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const toWorld = useCallback(
(minimapX: number, minimapY: number) => {
return {
x: (minimapX - offset.x) / scale + worldBounds.x,
y: (minimapY - offset.y) / scale + worldBounds.y,
};
},
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
);
const viewportRect = useMemo(() => {
const vx = -viewport.x / viewport.k;
const vy = -viewport.y / viewport.k;
const vw = viewportSize.width / viewport.k;
const vh = viewportSize.height / viewport.k;
const p1 = toMinimap(vx, vy);
const p2 = toMinimap(vx + vw, vy + vh);
return {
x: p1.x,
y: p1.y,
w: Math.max(p2.x - p1.x, 4),
h: Math.max(p2.y - p1.y, 4),
};
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const updateViewportFromEvent = (event: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
onViewportChange({
x: viewportSize.width / 2 - world.x * viewport.k,
y: viewportSize.height / 2 - world.y * viewport.k,
k: viewport.k,
});
};
return (
<div
className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm"
style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
<div
ref={containerRef}
className="relative h-full w-full cursor-crosshair"
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
updateViewportFromEvent(event);
}}
onPointerMove={(event) => {
if (isDragging) updateViewportFromEvent(event);
}}
onPointerUp={() => setIsDragging(false)}
onPointerLeave={() => setIsDragging(false)}
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
return (
return (
<div className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm" style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<div
key={node.id}
className="absolute rounded-[1px]"
style={{
left: pos.x,
top: pos.y,
width: Math.max(node.width * scale, 2),
height: Math.max(node.height * scale, 2),
backgroundColor: color,
opacity: 0.8,
}}
/>
);
})}
<div
className="pointer-events-none absolute border"
style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }}
/>
</div>
</div>
);
ref={containerRef}
className="relative h-full w-full cursor-crosshair"
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
updateViewportFromEvent(event);
}}
onPointerMove={(event) => {
if (isDragging) updateViewportFromEvent(event);
}}
onPointerUp={() => setIsDragging(false)}
onPointerLeave={() => setIsDragging(false)}
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
return (
<div
key={node.id}
className="absolute rounded-[1px]"
style={{
left: pos.x,
top: pos.y,
width: Math.max(node.width * scale, 2),
height: Math.max(node.height * scale, 2),
backgroundColor: color,
opacity: 0.8,
}}
/>
);
})}
<div className="pointer-events-none absolute border" style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }} />
</div>
</div>
);
}
@@ -5,87 +5,89 @@ import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react";
export type CanvasImageAngleParams = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
const defaultParams: CanvasImageAngleParams = {
horizontalAngle: 0,
pitchAngle: 9,
cameraDistance: 4.8,
wideAngle: false,
horizontalAngle: 0,
pitchAngle: 9,
cameraDistance: 4.8,
wideAngle: false,
};
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (params: CanvasImageAngleParams) => void;
}) {
const [params, setParams] = useState(defaultParams);
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
const [params, setParams] = useState(defaultParams);
useEffect(() => {
if (open) setParams(defaultParams);
}, [dataUrl, open]);
useEffect(() => {
if (open) setParams(defaultParams);
}, [dataUrl, open]);
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">AI </h2>
<p className="mt-1 text-sm opacity-60"></p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
<div className="grid flex-1 place-items-center">
<div className="relative">
<img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} />
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
</div>
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">AI </h2>
<p className="mt-1 text-sm opacity-60"></p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
<div className="grid flex-1 place-items-center">
<div className="relative">
<img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} />
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
</div>
</div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
</Button>
</div>
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<Segmented
className="w-fit"
value={params.wideAngle ? "wide" : "standard"}
options={[
{ label: "标准", value: "standard" },
{ label: "广角", value: "wide" },
]}
onChange={(value) => update("wideAngle", value === "wide")}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
AI
</Button>
</div>
</div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}></Button>
</div>
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<Segmented className="w-fit" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI </Button>
</div>
</div>
</Modal>
);
</Modal>
);
}
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return (
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">{label}</span>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
</div>
);
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
return (
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">{label}</span>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
<span className="whitespace-nowrap text-right font-semibold">
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix}
</span>
</div>
);
}
function previewTransform(params: CanvasImageAngleParams) {
const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0);
return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`;
const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0);
return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`;
}
@@ -7,10 +7,10 @@ import { Check, Lock, LockOpen, X } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
export type CanvasImageCropRect = {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
};
type DragMode = "move" | "resize";
@@ -20,151 +20,149 @@ const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
export function CanvasNodeCropDialog({
dataUrl,
open,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (crop: CanvasImageCropRect) => void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const boxRef = useRef<HTMLDivElement>(null);
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [locked, setLocked] = useState(false);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
useEffect(() => {
if (open) setCrop(defaultCrop);
}, [dataUrl, open]);
useEffect(() => {
if (open) setCrop(defaultCrop);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
const box = boxRef.current?.getBoundingClientRect();
if (!box) return;
event.preventDefault();
event.stopPropagation();
const start = { x: event.clientX, y: event.clientY, crop };
const move = (event: PointerEvent) => {
const dx = (event.clientX - start.x) / box.width;
const dy = (event.clientY - start.y) / box.height;
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
};
const up = () => {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
};
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-4">
<div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))}
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-4">
<div className="flex justify-center">
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
<CropMask crop={crop} />
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
{handles.map((handle) => (
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? (
<span>
{image.width} x {image.height}
</span>
) : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}>
</Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
</Button>
</div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
{image ? <span> {image.width} x {image.height}</span> : null}
</div>
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
{locked ? "锁定比例" : "自由比例"}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button icon={<X className="size-4" />} onClick={onClose}></Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}></Button>
</div>
</div>
</Modal>
);
</Modal>
);
}
function CropMask({ crop }: { crop: CanvasImageCropRect }) {
return (
<>
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
</>
);
return (
<>
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
</>
);
}
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
}
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect {
let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy;
if (handle.includes("w")) {
next.x = crop.x + dx;
next.width = crop.width - dx;
}
if (handle.includes("n")) {
next.y = crop.y + dy;
next.height = crop.height - dy;
}
if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width;
next.height = size / box.height;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height);
return next;
let next = { ...crop };
if (handle.includes("e")) next.width = crop.width + dx;
if (handle.includes("s")) next.height = crop.height + dy;
if (handle.includes("w")) {
next.x = crop.x + dx;
next.width = crop.width - dx;
}
if (handle.includes("n")) {
next.y = crop.y + dy;
next.height = crop.height - dy;
}
if (locked) {
const size = Math.max(next.width * box.width, next.height * box.height);
next.width = size / box.width;
next.height = size / box.height;
if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
}
next.width = clamp(next.width, minSize, 1);
next.height = clamp(next.height, minSize, 1);
next.x = clamp(next.x, 0, 1 - next.width);
next.y = clamp(next.y, 0, 1 - next.height);
return next;
}
function cropStyle(crop: CanvasImageCropRect) {
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
}
function handleStyle(handle: ResizeHandle) {
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
return { top, left, cursor: `${handle}-resize` };
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
return { top, left, cursor: `${handle}-resize` };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
return Math.min(max, Math.max(min, value));
}
function formatRatio(width: number, height: number) {
const divisor = gcd(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
const divisor = gcd(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
}
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : Math.max(1, a);
return b ? gcd(b, a % b) : Math.max(1, a);
}
@@ -1,100 +1,132 @@
import type { ChatCompletionMessage } from "@/services/api/image";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type NodeGenerationContext = {
prompt: string;
referenceImages: ReferenceImage[];
textCount: number;
imageCount: number;
prompt: string;
referenceImages: ReferenceImage[];
referenceVideos: ReferenceVideo[];
referenceAudios: ReferenceAudio[];
textCount: number;
imageCount: number;
videoCount: number;
audioCount: number;
};
export type NodeGenerationInput = {
nodeId: string;
type: "text" | "image";
title: string;
text?: string;
image?: ReferenceImage;
nodeId: string;
type: "text" | "image" | "video" | "audio";
title: string;
text?: string;
image?: ReferenceImage;
video?: ReferenceVideo;
audio?: ReferenceAudio;
};
export function buildNodeGenerationContext(
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
prompt: string,
): NodeGenerationContext {
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const upstreamText = inputs
.map((input) => input.text)
.filter(Boolean)
.join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = inputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
const referenceAudios = inputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages,
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length,
};
return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages,
referenceVideos,
referenceAudios,
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length,
videoCount: referenceVideos.length,
audioCount: referenceAudios.length,
};
}
export function buildNodeGenerationInputs(
nodeId: string,
nodes: CanvasNodeData[],
connections: CanvasConnection[],
): NodeGenerationInput[] {
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
});
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
const audio = readReferenceAudio(node);
if (audio) return [{ nodeId: node.id, type: "audio" as const, title: node.title, audio }];
const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return [];
});
}
export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompletionMessage[] {
if (!context.referenceImages.length) {
return [{ role: "user", content: context.prompt }];
}
if (!context.referenceImages.length) {
return [{ role: "user", content: context.prompt }];
}
return [
{
role: "user",
content: [
{ type: "text" as const, text: context.prompt },
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
],
},
];
return [
{
role: "user",
content: [{ type: "text" as const, text: context.prompt }, ...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } }))],
},
];
}
export async function hydrateNodeGenerationContext(context: NodeGenerationContext) {
const { imageToDataUrl } = await import("@/services/image-storage");
return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) };
const { imageToDataUrl } = await import("@/services/image-storage");
return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) };
}
function readNodeTextInput(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return node.metadata?.prompt || "";
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return node.metadata?.prompt || "";
}
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
};
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
};
}
function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
if (node.type !== CanvasNodeType.Video || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp4`,
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content,
storageKey: node.metadata.storageKey,
bytes: node.metadata.bytes,
width: node.metadata.naturalWidth,
height: node.metadata.naturalHeight,
durationMs: node.metadata.durationMs,
};
}
function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
if (node.type !== CanvasNodeType.Audio || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp3`,
type: node.metadata.mimeType || "audio/mpeg",
url: node.metadata.content,
storageKey: node.metadata.storageKey,
durationMs: node.metadata.durationMs,
};
}
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections
.filter((connection) => connection.toNodeId === nodeId)
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const order = target?.metadata?.inputOrder || [];
return [
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
...upstreamNodes.filter((node) => !order.includes(node.id)),
];
const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections
.filter((connection) => connection.toNodeId === nodeId)
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const order = target?.metadata?.inputOrder || [];
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
}
@@ -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, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
@@ -10,182 +10,207 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
type CanvasNodeHoverToolbarProps = {
node: CanvasNodeData | null;
viewport: ViewportTransform;
onKeep: (nodeId: string) => void;
onLeave: () => void;
onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
node: CanvasNodeData | null;
viewport: ViewportTransform;
onKeep: (nodeId: string) => void;
onLeave: () => void;
onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
};
export function CanvasNodeHoverToolbar({
node,
viewport,
onKeep,
onLeave,
onInfo,
onEditText,
onDecreaseFont,
onIncreaseFont,
onToggleDialog,
onGenerateImage,
onUpload,
onDownload,
onSaveAsset,
onCrop,
onAngle,
onRetry,
onToggleFreeResize,
onDelete,
node,
viewport,
onKeep,
onLeave,
onInfo,
onEditText,
onDecreaseFont,
onIncreaseFont,
onToggleDialog,
onGenerateImage,
onUpload,
onDownload,
onSaveAsset,
onCrop,
onAngle,
onViewImage,
onRetry,
onToggleFreeResize,
onDelete,
}: CanvasNodeHoverToolbarProps) {
if (!node) return null;
if (!node) return null;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const top = viewport.y + node.position.y * viewport.k - 14;
const isImage = node.type === CanvasNodeType.Image;
const hasImage = isImage && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage;
const canRetry = node.metadata?.status === "error";
const hasSpecificTools = canRetry || isText || isImage || isConfig;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const top = viewport.y + node.position.y * viewport.k - 14;
const isImage = node.type === CanvasNodeType.Image;
const isVideo = node.type === CanvasNodeType.Video;
const isAudio = node.type === CanvasNodeType.Audio;
const hasImage = isImage && Boolean(node.metadata?.content);
const hasVideo = isVideo && Boolean(node.metadata?.content);
const hasAudio = isAudio && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage || isVideo;
const canRetry = node.metadata?.status === "error";
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
return (
<div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={onLeave}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
{hasSpecificTools ? <ToolbarDivider /> : null}
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
{hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
{hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
{hasImage ? <ToolbarAction title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"} label={node.metadata?.freeResize ? "自由比例" : "锁比例"} icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />} onClick={() => onToggleFreeResize(node)} active={node.metadata?.freeResize} /> : null}
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
</div>
);
return (
<div
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
style={{ left, top }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={onLeave}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
{hasSpecificTools ? <ToolbarDivider /> : null}
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
{hasImage ? (
<ToolbarAction
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
onClick={() => onToggleFreeResize(node)}
active={node.metadata?.freeResize}
/>
) : null}
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
</div>
);
}
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
const json = useMemo(() => {
if (!node) return "";
return JSON.stringify(node, (key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
return value;
}, 2);
}, [node]);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
const json = useMemo(() => {
if (!node) return "";
return JSON.stringify(
node,
(key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
return value;
},
2,
);
}, [node]);
useEffect(() => {
if (open) setView("info");
}, [node?.id, open]);
useEffect(() => {
if (open) setView("info");
}, [node?.id, open]);
const title = (
<div className="flex items-center justify-between gap-4 pr-12">
<span></span>
<Segmented
size="small"
value={view}
onChange={(value) => setView(value as "info" | "json")}
options={[
{ label: "信息", value: "info" },
{ label: "JSON", value: "json" },
]}
/>
</div>
);
return (
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
{node ? (
<div className="h-[56vh] min-h-[360px] text-sm">
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>{node.metadata.errorDetails}</div> : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
const title = (
<div className="flex items-center justify-between gap-4 pr-12">
<span></span>
<Segmented
size="small"
value={view}
onChange={(value) => setView(value as "info" | "json")}
options={[
{ label: "信息", value: "info" },
{ label: "JSON", value: "json" },
]}
/>
</div>
) : null}
</Modal>
);
);
return (
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
{node ? (
<div className="h-[56vh] min-h-[360px] text-sm">
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? (
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
</div>
) : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
</div>
) : null}
</Modal>
);
}
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon}
<span>{label}</span>
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
</span>
</button>
</Tooltip>
);
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon}
<span>{label}</span>
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
</span>
</button>
</Tooltip>
);
}
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
</button>
</Tooltip>
);
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
</button>
</Tooltip>
);
}
function ToolbarDivider() {
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
}
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
<span className="opacity-50">{label}</span>
<span className="min-w-0 whitespace-pre-wrap break-words">{value}</span>
</div>
);
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
<span className="opacity-50">{label}</span>
<span className="min-w-0 whitespace-pre-wrap break-words">{value}</span>
</div>
);
}
@@ -2,110 +2,145 @@
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 { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
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 { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
type CanvasNodePromptPanelProps = {
node: CanvasNodeData;
isRunning: boolean;
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
node: CanvasNodeData;
isRunning: boolean;
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void;
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
const globalConfig = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? config.count : 1 });
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]);
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
}, [isEditingExistingContent, node.id]);
const updatePrompt = (value: string) => {
setPrompt(value);
if (!isEditingExistingContent) onPromptChange(node.id, value);
};
const updatePrompt = (value: string) => {
setPrompt(value);
if (!isEditingExistingContent) onPromptChange(node.id, value);
};
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return;
onGenerate(node.id, mode, text);
setPrompt("");
};
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return;
onGenerate(node.id, mode, text);
setPrompt("");
};
return (
<div
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<textarea
value={prompt}
onChange={(event) => updatePrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
submit();
}}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={mode === "image" ? hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容" : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
/>
return (
<div
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<textarea
value={prompt}
onChange={(event) => updatePrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
submit();
}}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
placeholder={mode === "video" ? "描述要生成的视频内容" : mode === "image" ? (hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容") : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
/>
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CanvasPromptLibrary onSelect={updatePrompt} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
{mode === "image" ? (
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} />
) : null}
{mode === "image" ? (
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 shrink-0 !w-[58px]" value={Math.floor(Math.abs(Number(config.count)) || 1)} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
) : null}
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CanvasPromptLibrary onSelect={updatePrompt} />
{mode === "image" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
<CanvasImageSettingsPopover
config={config}
placement="topLeft"
buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => openConfigDialog(true)}
onOpenChange={onImageSettingsOpenChange}
/>
</>
) : mode === "video" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
</>
) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
)}
</div>
<Button
type="primary"
className="!h-10 !min-w-16 shrink-0 !rounded-full !px-3"
disabled={isRunning || !prompt.trim()}
onClick={submit}
aria-label="生成"
>
<span className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-xs font-medium tabular-nums">
<CreditSymbol />
{credits.toLocaleString()}
</span>
{isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
</span>
</Button>
</div>
</div>
<Button
type="primary"
shape="circle"
className="!h-10 !w-10 !min-w-10 shrink-0"
disabled={isRunning || !prompt.trim()}
onClick={submit}
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
aria-label="生成"
/>
</div>
</div>
);
);
}
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text ? "text" : "image";
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : "image";
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
};
}
function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value };
if (key === "videoWatermark") return { watermark: value };
return { [key]: value };
}
File diff suppressed because it is too large Load Diff
@@ -6,73 +6,73 @@ import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
import { exportCanvasProjects } from "../utils/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
stopEditing();
};
const router = useRouter();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
stopEditing();
};
return (
<article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`}
/>
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button type="button" className="min-w-0 cursor-pointer text-left" onClick={(event) => { event.stopPropagation(); open(); }}>
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} · {project.connections.length} 线</p>
</button>
)}
</div>
<div className="mt-8 flex items-end justify-between gap-3">
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
{editing ? (
<>
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
)}
</div>
</div>
</article>
);
}
function exportProject(project: CanvasProject) {
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
link.click();
URL.revokeObjectURL(url);
return (
<article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selected}
onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`}
/>
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button
type="button"
className="min-w-0 cursor-pointer text-left"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
{project.nodes.length} · {project.connections.length} 线
</p>
</button>
)}
</div>
<div className="mt-8 flex items-end justify-between gap-3">
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
{editing ? (
<>
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
)}
</div>
</div>
</article>
);
}
@@ -9,15 +9,22 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<>
<Tooltip title="提示词库">
<Button shape="circle" className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent" style={{ borderColor: theme.node.stroke, color: theme.node.text }} icon={<BookOpen className="size-3.5" />} onClick={() => setOpen(true)} aria-label="提示词库" />
</Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
</>
);
return (
<>
<Tooltip title="提示词库">
<Button
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} />
</>
);
}
@@ -8,56 +8,60 @@ import { cn } from "@/lib/utils";
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
type CanvasSizePickerProps = {
value: string;
className?: string;
onChange: (value: string) => void;
value: string;
className?: string;
onChange: (value: string) => void;
};
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
const selectSize = (next: string) => {
onChange(next.trim());
setSearch("");
setOpen(false);
};
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
setOpen(false);
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
const selectSize = (next: string) => {
onChange(next.trim());
setSearch("");
setOpen(false);
};
window.addEventListener("pointerdown", close, true);
return () => window.removeEventListener("pointerdown", close, true);
}, [open]);
return (
<div ref={rootRef} className={className}>
<Select
showSearch
open={open}
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined}
searchValue={search}
placeholder="比例"
options={options}
popupMatchSelectWidth={false}
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
onOpenChange={setOpen}
onSearch={setSearch}
onChange={selectSize}
onBlur={() => {
if (search.trim()) selectSize(search);
}}
onInputKeyDown={(event) => {
if (event.key === "Enter" && search.trim()) selectSize(search);
}}
/>
</div>
);
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
setOpen(false);
};
window.addEventListener("pointerdown", close, true);
return () => window.removeEventListener("pointerdown", close, true);
}, [open]);
return (
<div ref={rootRef} className={className}>
<Select
showSearch
open={open}
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined}
searchValue={search}
placeholder="比例"
options={options}
popupMatchSelectWidth={false}
popupRender={(menu) => (
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
onOpenChange={setOpen}
onSearch={setSearch}
onChange={selectSize}
onBlur={() => {
if (search.trim()) selectSize(search);
}}
onInputKeyDown={(event) => {
if (event.key === "Enter" && search.trim()) selectSize(search);
}}
/>
</div>
);
}
@@ -1,266 +1,303 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { Button, Segmented } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Library, Moon, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload } from "lucide-react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
export function CanvasToolbar({
selectedCount,
canUndo,
canRedo,
backgroundMode,
onAddImage,
onAddText,
onAddConfig,
onUndo,
onRedo,
onUpload,
onDelete,
onClear,
onDeselect,
onBackgroundModeChange,
onOpenAssetLibrary,
onOpenMyAssets,
selectedCount,
canUndo,
canRedo,
backgroundMode,
showImageInfo,
onAddImage,
onAddVideo,
onAddAudio,
onAddText,
onAddConfig,
onUndo,
onRedo,
onUpload,
onDelete,
onClear,
onDeselect,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenAssetLibrary,
onOpenMyAssets,
}: {
selectedCount: number;
canUndo: boolean;
canRedo: boolean;
backgroundMode: CanvasBackgroundMode;
onAddImage: () => void;
onAddText: () => void;
onAddConfig: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onOpenAssetLibrary: () => void;
onOpenMyAssets: () => void;
selectedCount: number;
canUndo: boolean;
canRedo: boolean;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
onAddImage: () => void;
onAddVideo: () => void;
onAddAudio: () => void;
onAddText: () => void;
onAddConfig: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenAssetLibrary: () => void;
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [hovered, setHovered] = useState<string | null>(null);
const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
const wrapRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [hovered, setHovered] = useState<string | null>(null);
const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div
ref={wrapRef}
className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0"
style={dockStyle}
>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<Hand className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<Redo2 className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Type className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ImageIcon className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
<Library className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
<ToolbarButton
id="tool-style"
label="画布外观"
active={appearanceOpen}
hovered={hovered}
activeStyle={activeStyle}
hoverStyle={hoverStyle}
wrapRef={wrapRef}
onTipX={setTipX}
onHover={setHovered}
onClick={(event) => {
setPanelX(getTipX(wrapRef.current, event.currentTarget));
setAppearanceOpen((value) => !value);
}}
>
<Palette className="size-4.5" />
</ToolbarButton>
{selectedCount ? (
<>
<Divider theme={theme} />
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<Trash2 className="size-4.5" />
</ToolbarButton>
</>
) : null}
<Divider theme={theme} />
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" />
</ToolbarButton>
</div>
return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<Hand className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<Redo2 className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Type className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ImageIcon className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-video" label="视频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}>
<Video className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-audio" label="音频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}>
<Music2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
<Library className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
<ToolbarButton
id="tool-style"
label="画布外观"
active={appearanceOpen}
hovered={hovered}
activeStyle={activeStyle}
hoverStyle={hoverStyle}
wrapRef={wrapRef}
onTipX={setTipX}
onHover={setHovered}
onClick={(event) => {
setPanelX(getTipX(wrapRef.current, event.currentTarget));
setAppearanceOpen((value) => !value);
}}
>
<Palette className="size-4.5" />
</ToolbarButton>
{selectedCount ? (
<>
<Divider theme={theme} />
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<Trash2 className="size-4.5" />
</ToolbarButton>
</>
) : null}
<Divider theme={theme} />
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" />
</ToolbarButton>
</div>
{appearanceOpen ? (
<div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" />
</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" />
</CanvasThemeButton>
</div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-4" /></span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-4" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" /></span> },
]}
/>
{appearanceOpen ? (
<div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" />
</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" />
</CanvasThemeButton>
</div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{
value: "dots",
label: (
<span className="inline-flex items-center gap-1.5">
<CircleDot className="size-4" />
</span>
),
},
{
value: "lines",
label: (
<span className="inline-flex items-center gap-1.5">
<Grid2x2 className="size-4" />线
</span>
),
},
{
value: "blank",
label: (
<span className="inline-flex items-center gap-1.5">
<Square className="size-4" />
</span>
),
},
]}
/>
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg px-1.5 py-1">
<span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-medium opacity-65">
<Info className="size-3.5" />
</span>
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
</div>
</div>
) : null}
</div>
) : null}
</div>
);
);
}
function ToolbarButton({
id,
label,
active,
hovered,
activeStyle,
hoverStyle,
wrapRef,
onTipX,
onHover,
onClick,
disabled = false,
danger = false,
children,
id,
label,
active,
hovered,
activeStyle,
hoverStyle,
wrapRef,
onTipX,
onHover,
onClick,
disabled = false,
danger = false,
children,
}: {
id: string;
label: string;
active?: boolean;
hovered: string | null;
activeStyle?: CSSProperties;
hoverStyle: CSSProperties;
wrapRef: RefObject<HTMLDivElement | null>;
onTipX: (x: number) => void;
onHover: (id: string | null) => void;
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
disabled?: boolean;
danger?: boolean;
children: ReactNode;
id: string;
label: string;
active?: boolean;
hovered: string | null;
activeStyle?: CSSProperties;
hoverStyle: CSSProperties;
wrapRef: RefObject<HTMLDivElement | null>;
onTipX: (x: number) => void;
onHover: (id: string | null) => void;
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
disabled?: boolean;
danger?: boolean;
children: ReactNode;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<Button
type="text"
aria-label={label}
className="!h-8 !w-8 !min-w-8 !p-0"
disabled={disabled}
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
icon={children}
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }}
onMouseLeave={() => onHover(null)}
onClick={onClick}
/>
);
return (
<Button
type="text"
aria-label={label}
className="!h-8 !w-8 !min-w-8 !p-0"
disabled={disabled}
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
icon={children}
onMouseEnter={(event) => {
onHover(id);
onTipX(getTipX(wrapRef.current, event.currentTarget));
}}
onMouseLeave={() => onHover(null)}
onClick={onClick}
/>
);
}
function Divider({ theme }: { theme: CanvasTheme }) {
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
}
function CanvasThemeButton({
colorTheme,
targetTheme,
onThemeChange,
children,
}: {
colorTheme: CanvasColorTheme;
targetTheme: CanvasColorTheme;
onThemeChange: (theme: CanvasColorTheme) => void;
children: ReactNode;
}) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
return (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
}
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) {
return <span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>{label}</span>;
return (
<span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>
{label}
</span>
);
}
function toolLabel(id: string) {
if (id === "tool-hand") return "移动/选择";
if (id === "tool-undo") return "撤销";
if (id === "tool-redo") return "重做";
if (id === "tool-text") return "文本";
if (id === "tool-image") return "图片";
if (id === "tool-config") return "生成配置";
if (id === "tool-upload") return "上传图片";
if (id === "tool-library") return "素材库";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
return "";
if (id === "tool-hand") return "移动/选择";
if (id === "tool-undo") return "撤销";
if (id === "tool-redo") return "重做";
if (id === "tool-text") return "文本";
if (id === "tool-image") return "图片";
if (id === "tool-video") return "视频";
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-upload") return "上传素材";
if (id === "tool-library") return "素材库";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
return "";
}
function getTipX(wrap: HTMLDivElement | null, target: HTMLElement) {
if (!wrap) return 0;
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
const box = target.getBoundingClientRect();
return box.left - wrapBox.left + box.width / 2;
if (!wrap) return 0;
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
const box = target.getBoundingClientRect();
return box.left - wrapBox.left + box.width / 2;
}
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
type CanvasVideoSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <VideoSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">
{videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
</span>
</Button>
</span>
{panel}
</>
);
}
function VideoSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasVideoSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
}
@@ -7,68 +7,75 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
type CanvasZoomControlsProps = {
scale: number;
onScaleChange: (scale: number) => void;
onReset: () => void;
isMiniMapOpen: boolean;
onToggleMiniMap: () => void;
scale: number;
onScaleChange: (scale: number) => void;
onReset: () => void;
isMiniMapOpen: boolean;
onToggleMiniMap: () => void;
};
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
return (
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} icon={<Compass className="size-4" />} onClick={onToggleMiniMap} aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} />
</Tooltip>
<Tooltip title="重置视图">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
</Tooltip>
<Tooltip title="放大/缩小画布">
<input
type="range"
min="5"
max="500"
step="1"
value={Math.round(scale * 100)}
className="w-24"
style={{ accentColor: theme.node.activeStroke }}
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
aria-label="放大/缩小画布"
/>
</Tooltip>
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
{Math.round(scale * 100)}%
</span>
<Tooltip title="快捷键">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
</Tooltip>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" />
<Shortcut label="滚轮" value="缩放画布" />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
<Shortcut label="Delete / Backspace" value="删除选中" />
return (
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
<Button
type="text"
className="!h-8 !w-8 !min-w-8 !p-0"
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
icon={<Compass className="size-4" />}
onClick={onToggleMiniMap}
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
/>
</Tooltip>
<Tooltip title="重置视图">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
</Tooltip>
<Tooltip title="放大/缩小画布">
<input
type="range"
min="5"
max="500"
step="1"
value={Math.round(scale * 100)}
className="w-24"
style={{ accentColor: theme.node.activeStroke }}
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
aria-label="放大/缩小画布"
/>
</Tooltip>
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
{Math.round(scale * 100)}%
</span>
<Tooltip title="快捷键">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
</Tooltip>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" />
<Shortcut label="滚轮" value="缩放画布" />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
<Shortcut label="Delete / Backspace" value="删除选中" />
</div>
</Modal>
</div>
</Modal>
</div>
);
);
}
function Shortcut({ label, value }: { label: ReactNode; value: string }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-base font-medium">{label}</span>
<span className="opacity-60">{value}</span>
</div>
);
return (
<div className="flex items-center justify-between gap-4">
<span className="text-base font-medium">{label}</span>
<span className="opacity-60">{value}</span>
</div>
);
}
@@ -7,216 +7,209 @@ import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "../types";
type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode;
};
export function InfiniteCanvas({
containerRef,
viewport,
backgroundMode = "lines",
onViewportChange,
onCanvasMouseDown,
onCanvasDeselect,
onContextMenu,
onDrop,
children,
}: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
startX: 0,
startY: 0,
initialX: 0,
initialY: 0,
hasMoved: false,
});
const scaleRef = useRef(viewport.k);
const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
useEffect(() => {
scaleRef.current = viewport.k;
}, [viewport.k]);
useEffect(() => () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
setIsSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
const delta = -event.deltaY;
const factor = Math.pow(1.1, delta / 100);
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
onViewportChange({
x: mouseX - worldX * newScale,
y: mouseY - worldY * newScale,
k: newScale,
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-connection-create-menu]")) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
isPanning: true,
startX: event.clientX,
startY: event.clientY,
initialX: viewport.x,
initialY: viewport.y,
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
startX: 0,
startY: 0,
initialX: 0,
initialY: 0,
hasMoved: false,
};
document.body.style.cursor = "grabbing";
return;
}
});
const scaleRef = useRef(viewport.k);
const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
event.preventDefault();
}
};
useEffect(() => {
scaleRef.current = viewport.k;
}, [viewport.k]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
if (!panState.current.isPanning) return;
useEffect(
() => () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
},
[],
);
const dx = event.clientX - panState.current.startX;
const dy = event.clientY - panState.current.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panState.current.hasMoved = true;
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
setIsSpacePressed(true);
};
nextViewportRef.current = {
x: panState.current.initialX + dx,
y: panState.current.initialY + dy,
k: scaleRef.current,
};
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
});
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
const delta = -event.deltaY;
const factor = Math.pow(1.1, delta / 100);
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
onViewportChange({
x: mouseX - worldX * newScale,
y: mouseY - worldY * newScale,
k: newScale,
});
};
const handlePointerUp = () => {
if (!panState.current.isPanning) return;
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]");
if (!panState.current.hasMoved) {
onCanvasDeselect?.();
}
panState.current.isPanning = false;
document.body.style.cursor = "default";
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
isPanning: true,
startX: event.clientX,
startY: event.clientY,
initialX: viewport.x,
initialY: viewport.y,
hasMoved: false,
};
document.body.style.cursor = "grabbing";
return;
}
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
event.preventDefault();
}
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [onCanvasDeselect, onViewportChange]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
if (!panState.current.isPanning) return;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const dx = event.clientX - panState.current.startX;
const dy = event.clientY - panState.current.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panState.current.hasMoved = true;
}
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
nextViewportRef.current = {
x: panState.current.initialX + dx,
y: panState.current.initialY + dy,
k: scaleRef.current,
};
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
});
};
return (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown}
onWheel={handleWheel}
onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<CanvasGrid viewport={viewport} mode={backgroundMode} />
<div
className="absolute origin-top-left"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{children}
</div>
</div>
);
const handlePointerUp = () => {
if (!panState.current.isPanning) return;
if (!panState.current.hasMoved) {
onCanvasDeselect?.();
}
panState.current.isPanning = false;
document.body.style.cursor = "default";
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [onCanvasDeselect, onViewportChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
return (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown}
onWheel={handleWheel}
onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<CanvasGrid viewport={viewport} mode={backgroundMode} />
<div
className="absolute origin-top-left"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{children}
</div>
</div>
);
}
function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: CanvasBackgroundMode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (mode === "blank") return null;
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (mode === "blank") return null;
const gridSize = 48 * viewport.k;
const x = viewport.x % gridSize;
const y = viewport.y % gridSize;
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
const backgroundImage = mode === "dots"
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)`
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
const gridSize = 48 * viewport.k;
const x = viewport.x % gridSize;
const y = viewport.y % gridSize;
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
const backgroundImage =
mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
return (
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
backgroundImage,
backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${x}px ${y}px`,
}}
/>
);
return (
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
backgroundImage,
backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${x}px ${y}px`,
}}
/>
);
}
+30 -20
View File
@@ -2,33 +2,43 @@ import { CanvasNodeType } from "./types";
import type { CanvasNodeMetadata } from "./types";
type CanvasNodeSpec = {
width: number;
height: number;
title: string;
metadata?: CanvasNodeMetadata;
width: number;
height: number;
title: string;
metadata?: CanvasNodeMetadata;
};
export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
export const NODE_SPECS = {
[CanvasNodeType.Image]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Image],
metadata: { content: "", status: "idle" },
},
[CanvasNodeType.Text]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Text],
metadata: { content: "", status: "idle", fontSize: 14 },
},
[CanvasNodeType.Config]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
metadata: { content: "", status: "idle", generationMode: "image" },
},
[CanvasNodeType.Image]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Image],
metadata: { content: "", status: "idle" },
},
[CanvasNodeType.Text]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Text],
metadata: { content: "", status: "idle", fontSize: 14 },
},
[CanvasNodeType.Config]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
metadata: { content: "", status: "idle", generationMode: "image" },
},
[CanvasNodeType.Video]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Video],
metadata: { content: "", status: "idle" },
},
[CanvasNodeType.Audio]: {
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio],
metadata: { content: "", status: "idle" },
},
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
export function getNodeSpec(type: CanvasNodeType) {
return NODE_SPECS[type];
return NODE_SPECS[type];
}
+20
View File
@@ -0,0 +1,20 @@
import type { CanvasProject } from "./stores/use-canvas-store";
export type CanvasExportFile = {
app: "infinite-canvas";
version: 3;
exportedAt: string;
projects: CanvasProjectExportItem[];
};
export type CanvasProjectExportItem = {
project: CanvasProject;
files: CanvasExportAsset[];
};
export type CanvasExportAsset = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
+99 -69
View File
@@ -3,83 +3,113 @@
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { App, Button } from "antd";
import { FileUp, Plus } from "lucide-react";
import { Download, FileUp, Plus } from "lucide-react";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasDeleteProjectsDialog } from "./components/canvas-delete-projects-dialog";
import { CanvasProjectCard } from "./components/canvas-project-card";
import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
import type { CanvasExportFile } from "./export-types";
import { useCanvasStore } from "./stores/use-canvas-store";
import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
type CanvasExportFile = {
app: "infinite-canvas";
version: number;
exportedAt: string;
project: CanvasProject;
};
import { exportCanvasProjects } from "./utils/canvas-export";
export default function CanvasPage() {
const { message } = App.useApp();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const createProject = useCanvasStore((state) => state.createProject);
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const { message } = App.useApp();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const createProject = useCanvasStore((state) => state.createProject);
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const enterProject = (id: string) => {
router.push(`/canvas/${id}`);
};
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
const importCanvas = async (file?: File) => {
if (!file) return;
try {
const data = JSON.parse(await file.text()) as CanvasExportFile;
enterProject(importProject(data.project));
message.success("画布已导入");
} catch {
message.error("导入失败,请选择有效的 JSON 文件");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
const enterProject = (id: string) => {
router.push(`/canvas/${id}`);
};
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
const importCanvas = async (file?: File) => {
if (!file) return;
try {
const zip = await readZip(file);
const projectFile = zip.get("projects.json");
if (!projectFile) throw new Error("missing projects.json");
const data = JSON.parse(await projectFile.text()) as CanvasExportFile;
await Promise.all(
data.projects.flatMap((project) =>
project.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
),
);
data.projects.forEach((item) => importProject(item.project));
message.success(`已导入 ${data.projects.length} 个画布`);
} catch {
message.error("导入失败,请选择有效的画布压缩包");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
<div>
<p className="text-xs text-stone-500"></p>
<h1 className="mt-3 text-3xl font-semibold"></h1>
</div>
<div className="flex items-center gap-2">
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}></Button> : null}
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}></Button> : null}
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}></Button>
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}></Button>
</div>
</header>
return (
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
<div>
<p className="text-xs text-stone-500"></p>
<h1 className="mt-3 text-3xl font-semibold"></h1>
</div>
<div className="flex items-center gap-2">
{selectedIds.length ? (
<>
<Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}>
</Button>
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
</Button>
</>
) : null}
{projects.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
</Button>
) : null}
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
</Button>
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
</Button>
</div>
</header>
{!hydrated ? (
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section>
) : projects.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<CanvasProjectCard key={project.id} project={project} />
))}
</div>
) : (
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
<h2 className="text-xl font-medium"></h2>
<p className="mt-3 text-sm text-stone-500">线</p>
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}></Button>
</section>
)}
</div>
{!hydrated ? (
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">...</section>
) : projects.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<CanvasProjectCard key={project.id} project={project} />
))}
</div>
) : (
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
<h2 className="text-xl font-medium"></h2>
<p className="mt-3 text-sm text-stone-500">线</p>
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
</Button>
</section>
)}
</div>
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog />
</main>
);
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog />
</main>
);
}
@@ -7,27 +7,28 @@ import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "../types";
export type CanvasProject = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
backgroundMode: CanvasBackgroundMode;
viewport: ViewportTransform;
id: string;
title: string;
createdAt: string;
updatedAt: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
};
type CanvasStore = {
hydrated: boolean;
projects: CanvasProject[];
createProject: (title?: string) => string;
importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void;
hydrated: boolean;
projects: CanvasProject[];
createProject: (title?: string) => string;
importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
@@ -37,89 +38,95 @@ let saveTimer: ReturnType<typeof setTimeout> | null = null;
let queuedPersistState: PersistedCanvasState | null = null;
const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
queuedPersistState = parsed.state as PersistedCanvasState;
return parsed;
},
setItem: (name, value) => {
const nextState = value.state as PersistedCanvasState;
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
queuedPersistState = nextState;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void localForageStorage.setItem(name, JSON.stringify(value));
}, 400);
},
removeItem: (name) => localForageStorage.removeItem(name),
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
queuedPersistState = parsed.state as PersistedCanvasState;
return parsed;
},
setItem: (name, value) => {
const nextState = value.state as PersistedCanvasState;
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
queuedPersistState = nextState;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void localForageStorage.setItem(name, JSON.stringify(value));
}, 400);
},
removeItem: (name) => localForageStorage.removeItem(name),
};
export const useCanvasStore = create<CanvasStore>()(
persist(
(set, get) => ({
hydrated: false,
projects: [],
createProject: (title = "未命名画布") => {
const now = new Date().toISOString();
const id = nanoid();
const project: CanvasProject = {
id,
title,
createdAt: now,
updatedAt: now,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "lines",
viewport: initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return id;
},
importProject: (source) => {
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
title: source.title || "导入画布",
createdAt: source.createdAt || now,
updatedAt: now,
nodes: source.nodes || [],
connections: source.connections || [],
chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null,
backgroundMode: source.backgroundMode || "lines",
viewport: source.viewport || initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return project.id;
},
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
renameProject: (id, title) => set((state) => ({
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project),
})),
deleteProjects: (ids) => set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
updateProject: (id, patch) => set((state) => ({
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project),
})),
}),
{
name: CANVAS_STORE_KEY,
storage: canvasStorage,
partialize: (state) => ({
projects: state.projects,
}) as StorageValue<CanvasStore>["state"],
onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
persist(
(set, get) => ({
hydrated: false,
projects: [],
createProject: (title = "未命名画布") => {
const now = new Date().toISOString();
const id = nanoid();
const project: CanvasProject = {
id,
title,
createdAt: now,
updatedAt: now,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "lines",
showImageInfo: false,
viewport: initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return id;
},
importProject: (source) => {
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
title: source.title || "导入画布",
createdAt: source.createdAt || now,
updatedAt: now,
nodes: source.nodes || [],
connections: source.connections || [],
chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null,
backgroundMode: source.backgroundMode || "lines",
showImageInfo: source.showImageInfo || false,
viewport: source.viewport || initialViewport,
};
set((state) => ({ projects: [project, ...state.projects] }));
return project.id;
},
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
renameProject: (id, title) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
})),
deleteProjects: (ids) =>
set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
updateProject: (id, patch) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
})),
}),
{
name: CANVAS_STORE_KEY,
storage: canvasStorage,
partialize: (state) =>
({
projects: state.projects,
}) as StorageValue<CanvasStore>["state"],
onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
);
@@ -1,27 +1,27 @@
import { create } from "zustand";
type CanvasUiStore = {
editingProjectId: string | null;
editingProjectTitle: string;
selectedProjectIds: string[];
deleteProjectIds: string[];
startEditingProject: (id: string, title: string) => void;
setEditingProjectTitle: (title: string) => void;
stopEditingProject: () => void;
toggleSelectedProjectId: (id: string, selected: boolean) => void;
setDeleteProjectIds: (ids: string[]) => void;
removeSelectedProjectIds: (ids: string[]) => void;
editingProjectId: string | null;
editingProjectTitle: string;
selectedProjectIds: string[];
deleteProjectIds: string[];
startEditingProject: (id: string, title: string) => void;
setEditingProjectTitle: (title: string) => void;
stopEditingProject: () => void;
toggleSelectedProjectId: (id: string, selected: boolean) => void;
setDeleteProjectIds: (ids: string[]) => void;
removeSelectedProjectIds: (ids: string[]) => void;
};
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
editingProjectId: null,
editingProjectTitle: "",
selectedProjectIds: [],
deleteProjectIds: [],
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
stopEditingProject: () => set({ editingProjectId: null }),
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
editingProjectId: null,
editingProjectTitle: "",
selectedProjectIds: [],
deleteProjectIds: [],
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
stopEditingProject: () => set({ editingProjectId: null }),
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
}));
+83 -77
View File
@@ -1,120 +1,126 @@
export type Position = {
x: number;
y: number;
x: number;
y: number;
};
export type ViewportTransform = {
x: number;
y: number;
k: number;
x: number;
y: number;
k: number;
};
export enum CanvasNodeType {
Image = "image",
Text = "text",
Config = "config",
Image = "image",
Text = "text",
Config = "config",
Video = "video",
Audio = "audio",
}
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasGenerationMode = "text" | "image";
export type CanvasGenerationMode = "text" | "image" | "video";
export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeMetadata = {
content?: string;
prompt?: string;
status?: CanvasNodeStatus;
errorDetails?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
model?: string;
size?: string;
quality?: string;
count?: number;
references?: string[];
naturalWidth?: number;
naturalHeight?: number;
freeResize?: boolean;
isBatchRoot?: boolean;
batchRootId?: string;
batchChildIds?: string[];
batchUsesReferenceImages?: boolean;
primaryImageId?: string;
imageBatchExpanded?: boolean;
inputOrder?: string[];
storageKey?: string;
mimeType?: string;
bytes?: number;
content?: string;
prompt?: string;
status?: CanvasNodeStatus;
errorDetails?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
model?: string;
size?: string;
quality?: string;
count?: number;
seconds?: string;
vquality?: string;
generateAudio?: string;
watermark?: string;
references?: string[];
naturalWidth?: number;
naturalHeight?: number;
freeResize?: boolean;
isBatchRoot?: boolean;
batchRootId?: string;
batchChildIds?: string[];
batchUsesReferenceImages?: boolean;
primaryImageId?: string;
imageBatchExpanded?: boolean;
inputOrder?: string[];
storageKey?: string;
mimeType?: string;
bytes?: number;
durationMs?: number;
};
export type CanvasNodeData = {
id: string;
type: CanvasNodeType;
title: string;
position: Position;
width: number;
height: number;
metadata?: CanvasNodeMetadata;
id: string;
type: CanvasNodeType;
title: string;
position: Position;
width: number;
height: number;
metadata?: CanvasNodeMetadata;
};
export type CanvasConnection = {
id: string;
fromNodeId: string;
toNodeId: string;
id: string;
fromNodeId: string;
toNodeId: string;
};
export type CanvasAssistantReference = {
id: string;
type: CanvasNodeType;
title: string;
dataUrl?: string;
storageKey?: string;
text?: string;
id: string;
type: CanvasNodeType;
title: string;
dataUrl?: string;
storageKey?: string;
text?: string;
};
export type CanvasAssistantImage = {
id: string;
dataUrl: string;
storageKey?: string;
prompt: string;
id: string;
dataUrl: string;
storageKey?: string;
prompt: string;
};
export type CanvasAssistantMessage = {
id: string;
role: "user" | "assistant";
mode: "ask" | "image";
text: string;
isLoading?: boolean;
references?: CanvasAssistantReference[];
images?: CanvasAssistantImage[];
id: string;
role: "user" | "assistant";
mode: "ask" | "image";
text: string;
isLoading?: boolean;
references?: CanvasAssistantReference[];
images?: CanvasAssistantImage[];
};
export type CanvasAssistantSession = {
id: string;
title: string;
messages: CanvasAssistantMessage[];
createdAt: string;
updatedAt: string;
id: string;
title: string;
messages: CanvasAssistantMessage[];
createdAt: string;
updatedAt: string;
};
export type ConnectionHandle = {
nodeId: string;
handleType: "source" | "target";
nodeId: string;
handleType: "source" | "target";
};
export type SelectionBox = {
startWorldX: number;
startWorldY: number;
currentWorldX: number;
currentWorldY: number;
additive: boolean;
initialSelectedNodeIds: string[];
startWorldX: number;
startWorldY: number;
currentWorldX: number;
currentWorldY: number;
additive: boolean;
initialSelectedNodeIds: string[];
};
export type ContextMenuState =
{
export type ContextMenuState = {
type: "node";
x: number;
y: number;
nodeId: string;
};
};
@@ -0,0 +1,51 @@
import { saveAs } from "file-saver";
import { createZip } from "@/lib/zip";
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "../export-types";
import type { CanvasProject } from "../stores/use-canvas-store";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
const files: CanvasExportAsset[] = [];
await Promise.all(
collectStorageKeys(project).map(async (storageKey) => {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
return { project, files };
}),
);
const data: CanvasExportFile = { app: "infinite-canvas", version: 3, exportedAt: new Date().toISOString(), projects: exportedProjects };
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
return storageKey.startsWith("image:") ? "png" : "bin";
}
@@ -1,90 +1,84 @@
"use client";
export type ImageCropRect = {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
};
export type ImageAngleTransform = {
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
horizontalAngle: number;
pitchAngle: number;
cameraDistance: number;
wideAngle: boolean;
};
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
const image = await loadImage(dataUrl);
if (crop) {
return drawCrop(
image,
Math.floor(crop.x * image.width),
Math.floor(crop.y * image.height),
Math.ceil(crop.width * image.width),
Math.ceil(crop.height * image.height),
);
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
const image = await loadImage(dataUrl);
if (crop) {
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
}
const size = Math.min(image.width, image.height);
const sx = Math.max(0, Math.floor((image.width - size) / 2));
const sy = Math.max(0, Math.floor((image.height - size) / 2));
return drawCrop(image, sx, sy, size, size);
}
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d");
if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height);
const image = await loadImage(dataUrl);
const canvas = document.createElement("canvas");
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
canvas.width = image.width + padding * 2;
canvas.height = image.height + padding * 2;
const context = canvas.getContext("2d");
if (!context) return dataUrl;
context.clearRect(0, 0, canvas.width, canvas.height);
const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45;
const horizontal = params.horizontalAngle / 60;
const pitch = params.pitchAngle / 45;
const distanceScale = 1.12 - params.cameraDistance * 0.035;
const wideScale = params.wideAngle ? 0.88 : 1;
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const skewX = horizontal * image.width * 0.18;
const skewY = pitch * image.height * 0.12;
const x = cx - width / 2 + horizontal * padding * 0.5;
const y = cy - height / 2 + pitch * padding * 0.45;
context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore();
context.save();
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
context.drawImage(image, x + skewX, y + skewY, width, height);
context.restore();
if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
if (params.wideAngle) {
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas.toDataURL("image/png");
return canvas.toDataURL("image/png");
}
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d");
if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, sw);
canvas.height = Math.max(1, sh);
const context = canvas.getContext("2d");
if (!context) return image.src;
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.src = dataUrl;
});
return new Promise<HTMLImageElement>((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.src = dataUrl;
});
}
@@ -0,0 +1,18 @@
"use client";
export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const scale = Math.min(1, maxWidth / w, maxHeight / h);
return { width: w * scale, height: h * scale };
}
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
const match = size?.match(/^(\d+)(?:x|:)(\d+)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
const ratio = width / Math.max(1, height);
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
return ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight };
}
File diff suppressed because it is too large Load Diff

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