Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 796882064b | |||
| 8c506f92f9 | |||
| b21f8c3af9 | |||
| d8cb1d6ed2 | |||
| b8e50c10f6 | |||
| 030541b99d | |||
| 2934b1d0bc | |||
| 01f2a4d9d5 | |||
| 823fb2523f | |||
| 2181b3b885 | |||
| 392bd49d8c | |||
| fff7074218 | |||
| fe3294ed60 | |||
| 51ea17e8d9 | |||
| b2cae2471d | |||
| f871a5d015 | |||
| ef7772a703 | |||
| 370263cd83 | |||
| 44c5825cb0 | |||
| 0cc27b5a4c | |||
| 83031e0864 | |||
| 593feddc3f | |||
| c9682481d7 | |||
| 2e4c0d0f0a | |||
| 697e1fc1eb | |||
| ca4e87bddf | |||
| e319481976 | |||
| cee08cb5a8 | |||
| 133861590e | |||
| 6049ad2df3 | |||
| 03396a9d18 |
@@ -59,6 +59,7 @@
|
|||||||
- 优先使用 `canvasThemes`、`useThemeStore` 或 Ant Design `ConfigProvider` token。
|
- 优先使用 `canvasThemes`、`useThemeStore` 或 Ant Design `ConfigProvider` token。
|
||||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||||
|
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 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 代理、图片节点生成与重试、参考图缺失处理等细节。
|
+ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。
|
||||||
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
|
+ [优化] 后端模型代理路径调整为 OpenAI 风格。
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ type Config struct {
|
|||||||
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
|
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
|
||||||
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
|
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
|
||||||
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
|
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
|
||||||
|
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
|
var Cfg Config
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后端数据库说明
|
# 后端数据库说明
|
||||||
|
|
||||||
本文档记录后端当前已经使用,以及后续规划会用到的主要数据表。
|
本文档只记录后端当前已经使用的主要数据表。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
@@ -15,15 +15,16 @@
|
|||||||
当前启动时执行 `AutoMigrate`,自动维护以下表:
|
当前启动时执行 `AutoMigrate`,自动维护以下表:
|
||||||
|
|
||||||
- `users`
|
- `users`
|
||||||
|
- `credit_logs`
|
||||||
- `prompts`
|
- `prompts`
|
||||||
- `assets`
|
- `assets`
|
||||||
- `settings`
|
- `settings`
|
||||||
|
|
||||||
后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表。
|
后续新增表时再同步补充本文档,未实际使用的规划表不提前写入。
|
||||||
|
|
||||||
### users
|
### users
|
||||||
|
|
||||||
系统用户表。用户基础信息、角色、算力点余额和邀请关系放在该表中。
|
系统用户表。用户基础信息、角色、算力点余额和第三方登录标识放在该表中。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|-----------------|--------|--------------------------|
|
|-----------------|--------|--------------------------|
|
||||||
@@ -34,22 +35,22 @@
|
|||||||
| `display_name` | string | 昵称 |
|
| `display_name` | string | 昵称 |
|
||||||
| `avatar_url` | string | 头像地址 |
|
| `avatar_url` | string | 头像地址 |
|
||||||
| `role` | string | 角色:`user`、`admin` |
|
| `role` | string | 角色:`user`、`admin` |
|
||||||
| `credits` | number | 算力点余额,规划字段 |
|
| `credits` | number | 算力点余额 |
|
||||||
| `aff_code` | string | 用户自己的邀请码,唯一索引,规划字段 |
|
| `aff_code` | string | 用户自己的邀请码,唯一索引 |
|
||||||
| `aff_count` | number | 已邀请用户数量,冗余统计字段,规划字段 |
|
| `aff_count` | number | 已邀请用户数量,冗余统计字段 |
|
||||||
| `inviter_id` | string | 邀请人用户 ID,规划字段 |
|
| `inviter_id` | string | 邀请人用户 ID |
|
||||||
| `github_id` | string | GitHub 用户 ID,规划字段 |
|
| `github_id` | string | GitHub 用户 ID |
|
||||||
| `linux_do_id` | string | Linux.do 用户 ID,规划字段 |
|
| `linux_do_id` | string | Linux.do 用户 ID |
|
||||||
| `wechat_id` | string | 微信用户 ID,规划字段 |
|
| `wechat_id` | string | 微信用户 ID |
|
||||||
| `status` | string | 用户状态:`active`、`ban`,规划字段 |
|
| `status` | string | 用户状态:`active`、`ban` |
|
||||||
| `last_login_at` | string | 最近登录时间 |
|
| `last_login_at` | string | 最近登录时间 |
|
||||||
| `extra` | json | 扩展信息 |
|
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
|
||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
| `updated_at` | string | 更新时间 |
|
| `updated_at` | string | 更新时间 |
|
||||||
|
|
||||||
### prompts
|
### prompts
|
||||||
|
|
||||||
提示词表。后续公开提示词、内置 GitHub 系统提示词、分类和扩展信息都优先放在该表字段或 JSON 中。
|
提示词表。用于保存公开提示词、内置 GitHub 系统提示词、分类和预览内容。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|--------------|--------|------------------------------|
|
|--------------|--------|------------------------------|
|
||||||
@@ -59,9 +60,7 @@
|
|||||||
| `prompt` | string | 提示词内容 |
|
| `prompt` | string | 提示词内容 |
|
||||||
| `tags` | json | 标签列表 |
|
| `tags` | json | 标签列表 |
|
||||||
| `category` | string | 分类标识 |
|
| `category` | string | 分类标识 |
|
||||||
| `visibility` | string | 可见性:公开、私有、系统内置等,规划字段 |
|
|
||||||
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
|
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
|
||||||
| `extra` | json | 扩展信息 |
|
|
||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
| `updated_at` | string | 更新时间 |
|
| `updated_at` | string | 更新时间 |
|
||||||
|
|
||||||
@@ -69,25 +68,19 @@
|
|||||||
|
|
||||||
### assets
|
### assets
|
||||||
|
|
||||||
素材表。当前用于素材库;后续通过 `user_id`、`visibility`、`type` 区分系统公开素材和用户私有素材。
|
素材表。当前用于后台素材库。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------------------|--------|-------------------------------|
|
|------------------|--------|-------------------------------|
|
||||||
| `id` | string | 主键 |
|
| `id` | string | 主键 |
|
||||||
| `user_id` | string | 所属用户,为空或系统用户表示公开素材,规划字段 |
|
|
||||||
| `title` | string | 标题 |
|
| `title` | string | 标题 |
|
||||||
| `type` | string | 素材类型:`text`、`image`、`video` 等 |
|
| `type` | string | 素材类型:`text`、`image`、`video` 等 |
|
||||||
| `visibility` | string | 可见性:公开、私有,规划字段 |
|
|
||||||
| `cover_url` | string | 封面图 |
|
| `cover_url` | string | 封面图 |
|
||||||
| `tags` | json | 标签列表 |
|
| `tags` | json | 标签列表 |
|
||||||
| `category` | string | 分类标识 |
|
| `category` | string | 分类标识 |
|
||||||
| `description` | string | 描述 |
|
| `description` | string | 描述 |
|
||||||
| `content` | text | 文本或 Markdown 内容 |
|
| `content` | text | 文本或 Markdown 内容 |
|
||||||
| `url` | string | 图片、视频等媒体地址 |
|
| `url` | string | 图片、视频等媒体地址 |
|
||||||
| `like_count` | number | 点赞量,规划字段 |
|
|
||||||
| `favorite_count` | number | 收藏量,规划字段 |
|
|
||||||
| `view_count` | number | 查看量,规划字段 |
|
|
||||||
| `extra` | json | 扩展信息,规划字段 |
|
|
||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
| `updated_at` | string | 更新时间 |
|
| `updated_at` | string | 更新时间 |
|
||||||
|
|
||||||
@@ -102,8 +95,8 @@
|
|||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
| `updated_at` | string | 更新时间 |
|
| `updated_at` | string | 更新时间 |
|
||||||
|
|
||||||
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。
|
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、登录开关等。
|
||||||
`private.value` 常放渠道密钥、支付配置、奖励规则、后台内部开关等。
|
`private.value` 常放渠道密钥、登录密钥、后台内部开关等。
|
||||||
|
|
||||||
当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。
|
当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。
|
||||||
|
|
||||||
@@ -112,23 +105,41 @@
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|-------------------|----------|----------------|
|
|-------------------|----------|----------------|
|
||||||
| `modelChannel` | object | 模型渠道公开配置组 |
|
| `modelChannel` | object | 模型渠道公开配置组 |
|
||||||
|
| `auth` | object | 公开登录配置 |
|
||||||
|
|
||||||
`modelChannel` 当前字段:
|
`modelChannel` 当前字段:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|-------------------|----------|----------------|
|
|-------------------|----------|----------------|
|
||||||
| `availableModels` | string[] | 系统可用模型列表 |
|
| `availableModels` | string[] | 系统可用模型列表 |
|
||||||
|
| `modelCosts` | object[] | 模型算力点配置 |
|
||||||
| `defaultModel` | string | 默认模型 |
|
| `defaultModel` | string | 默认模型 |
|
||||||
| `defaultImageModel` | string | 默认图片模型 |
|
| `defaultImageModel` | string | 默认图片模型 |
|
||||||
|
| `defaultVideoModel` | string | 默认视频模型 |
|
||||||
| `defaultTextModel` | string | 默认文本模型 |
|
| `defaultTextModel` | string | 默认文本模型 |
|
||||||
| `systemPrompt` | string | 系统提示词 |
|
| `systemPrompt` | string | 系统提示词 |
|
||||||
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 |
|
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 |
|
||||||
|
|
||||||
|
`modelCosts` 每项字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `model` | string | 模型名称 |
|
||||||
|
| `credits` | number | 每次后端模型接口调用前预扣的算力点,未配置默认不扣除 |
|
||||||
|
|
||||||
|
`auth.linuxDo` 当前字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | bool | 是否开启 Linux.do 登录 |
|
||||||
|
|
||||||
`private.value` 当前字段:
|
`private.value` 当前字段:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------------|----------|----------|
|
|------------|----------|----------|
|
||||||
| `channels` | object[] | 模型渠道配置列表 |
|
| `channels` | object[] | 模型渠道配置列表 |
|
||||||
|
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||||
|
| `auth` | object | 私有登录配置 |
|
||||||
|
|
||||||
`channels` 每项字段:
|
`channels` 每项字段:
|
||||||
|
|
||||||
@@ -143,133 +154,42 @@
|
|||||||
| `enabled` | bool | 是否启用 |
|
| `enabled` | bool | 是否启用 |
|
||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
|
||||||
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
`promptSync` 字段:
|
||||||
|
|
||||||
### dicts
|
|
||||||
|
|
||||||
字典表。一个字典一行,具体字典项数据放在 `items`。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|--------------|--------|-------|
|
| --- | --- | --- |
|
||||||
| `code` | string | 字典编码 |
|
| `enabled` | bool | 是否开启定时同步,默认开启 |
|
||||||
| `name` | string | 字典名称 |
|
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||||
| `remark` | string | 备注 |
|
|
||||||
| `items` | text | 字典值数据 |
|
|
||||||
| `created_at` | string | 创建时间 |
|
|
||||||
| `updated_at` | string | 更新时间 |
|
|
||||||
|
|
||||||
可维护分类、标签、业务枚举、模型分类、日志类型等。
|
`auth.linuxDo` 当前字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `clientId` | string | Linux.do OAuth App Client ID |
|
||||||
|
| `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 |
|
||||||
|
|
||||||
|
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||||
|
|
||||||
### credit_logs
|
### credit_logs
|
||||||
|
|
||||||
用户算力点变更流水表。充值、消费、订阅扣减、邀请奖励、后台调整等余额变化都写入该表。
|
用户算力点变更流水表。当前记录后台手动调整、模型调用预扣和模型调用失败返还。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|--------------|--------|--------------------------|
|
|--------------|--------|--------------------------|
|
||||||
| `id` | string | 主键 |
|
| `id` | string | 主键 |
|
||||||
| `user_id` | string | 关联用户 ID |
|
| `user_id` | string | 关联用户 ID |
|
||||||
| `type` | string | 类型:充值、消费、订阅扣减、邀请奖励、后台调整等 |
|
| `type` | string | 类型:`admin_adjust`、`ai_consume`、`ai_refund` |
|
||||||
| `amount` | number | 本次变动数量,增加为正,扣减为负 |
|
| `amount` | number | 本次变动数量,增加为正,扣减为负 |
|
||||||
| `balance` | number | 变动后的用户算力点余额 |
|
| `balance` | number | 变动后的用户算力点余额 |
|
||||||
| `related_id` | string | 关联订单、任务或日志 ID,可为空 |
|
| `related_id` | string | 关联业务 ID,可为空 |
|
||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
| `extra` | json | 扩展信息 |
|
| `extra` | json | 扩展信息 |
|
||||||
| `created_at` | string | 创建时间 |
|
| `created_at` | string | 创建时间 |
|
||||||
|
|
||||||
### orders
|
`type` 当前取值:
|
||||||
|
|
||||||
订单表。统一记录充值、订阅购买等支付订单。
|
| 值 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
| 字段 | 类型 | 说明 |
|
| `admin_adjust` | 后台手动调整 |
|
||||||
|---------------------|--------|----------------------|
|
| `ai_consume` | 调用后端模型接口消费 |
|
||||||
| `id` | string | 主键 |
|
| `ai_refund` | 后端模型接口调用失败返还 |
|
||||||
| `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 | 完成时间 |
|
|
||||||
|
|||||||
@@ -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:canvas_store`。
|
||||||
- 我的素材 JSON:`localForage`,数据库名 `infinite-canvas`,storeName `app_state`,key 为 `infinite-canvas:asset_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 `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
|
```ts
|
||||||
type CanvasNodeData = {
|
type CanvasNodeData = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "image" | "text" | "config";
|
type: "image" | "text" | "config" | "video";
|
||||||
title: string;
|
title: string;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
width: number;
|
width: number;
|
||||||
@@ -62,7 +63,7 @@ type CanvasNodeData = {
|
|||||||
通用字段:
|
通用字段:
|
||||||
|
|
||||||
- `id`:节点 ID。
|
- `id`:节点 ID。
|
||||||
- `type`:节点类型,当前有图片、文本、生成配置三类。
|
- `type`:节点类型,当前有图片、文本、生成配置、视频四类。
|
||||||
- `title`:节点标题。
|
- `title`:节点标题。
|
||||||
- `position`:画布世界坐标,不是屏幕坐标。
|
- `position`:画布世界坐标,不是屏幕坐标。
|
||||||
- `width` / `height`:画布世界坐标下的节点尺寸。
|
- `width` / `height`:画布世界坐标下的节点尺寸。
|
||||||
@@ -77,7 +78,7 @@ type CanvasNodeMetadata = {
|
|||||||
status?: "idle" | "success" | "loading" | "error";
|
status?: "idle" | "success" | "loading" | "error";
|
||||||
errorDetails?: string;
|
errorDetails?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
generationMode?: "text" | "image";
|
generationMode?: "text" | "image" | "video";
|
||||||
model?: string;
|
model?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -99,8 +100,9 @@ type CanvasNodeMetadata = {
|
|||||||
不同节点的使用方式:
|
不同节点的使用方式:
|
||||||
|
|
||||||
- 图片节点:`content` 是当前可展示的图片 URL,通常是 `blob:` URL;`storageKey` 指向本地图片 Blob;`naturalWidth/naturalHeight/bytes/mimeType` 保存原图信息。
|
- 图片节点:`content` 是当前可展示的图片 URL,通常是 `blob:` URL;`storageKey` 指向本地图片 Blob;`naturalWidth/naturalHeight/bytes/mimeType` 保存原图信息。
|
||||||
|
- 视频节点:`content` 是当前可播放的视频 URL,通常是 `blob:` URL;`storageKey` 指向本地视频 Blob;`bytes/mimeType` 保存文件信息。
|
||||||
- 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。
|
- 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。
|
||||||
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;上游输入通过 `connections` 计算。
|
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;`generationMode` 可选择文本、图片或视频;上游输入通过 `connections` 计算。
|
||||||
- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。
|
- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。
|
||||||
|
|
||||||
## 连线结构
|
## 连线结构
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
- 每个生成出来的图片节点都会在自身 `metadata` 下记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,方便后续重试。
|
- 每个生成出来的图片节点都会在自身 `metadata` 下记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,方便后续重试。
|
||||||
- 如果重试时参考图已经丢失或无法恢复,系统会直接提示,避免误发空的图生图请求。
|
- 如果重试时参考图已经丢失或无法恢复,系统会直接提示,避免误发空的图生图请求。
|
||||||
|
|
||||||
|
### 视频节点
|
||||||
|
|
||||||
|
- 工具栏可以新建视频节点,也可以拖入或上传本地视频文件。
|
||||||
|
- 视频节点使用原生播放器展示内容,可在节点内直接播放、暂停和拖动进度。
|
||||||
|
- 空视频节点下方对话框可输入提示词生成视频,结果会回填到当前节点。
|
||||||
|
- 从文本、图片或配置节点创建视频生成时,会在右侧生成新的视频节点并自动连接。
|
||||||
|
- 视频生成接口使用 OpenAI 兼容的 `POST /v1/videos`、`GET /v1/videos/{id}` 和 `GET /v1/videos/{id}/content`。
|
||||||
|
|
||||||
### 推荐流程
|
### 推荐流程
|
||||||
|
|
||||||
1. 新建文本节点,写入图片创作想法。
|
1. 新建文本节点,写入图片创作想法。
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。
|
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||||
|
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
|
||||||
|
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
|
||||||
|
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
|
||||||
|
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name`、`preset=normal`、`input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
|
||||||
|
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
|
||||||
|
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
|
||||||
|
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
|
||||||
|
|||||||
@@ -13,11 +13,21 @@
|
|||||||
{
|
{
|
||||||
"modelChannel": {
|
"modelChannel": {
|
||||||
"availableModels": ["gpt-5.5", "gpt-image-2"],
|
"availableModels": ["gpt-5.5", "gpt-image-2"],
|
||||||
|
"modelCosts": [
|
||||||
|
{ "model": "gpt-5.5", "credits": 1 },
|
||||||
|
{ "model": "gpt-image-2", "credits": 10 }
|
||||||
|
],
|
||||||
"defaultModel": "gpt-image-2",
|
"defaultModel": "gpt-image-2",
|
||||||
"defaultImageModel": "gpt-image-2",
|
"defaultImageModel": "gpt-image-2",
|
||||||
"defaultTextModel": "gpt-5.5",
|
"defaultTextModel": "gpt-5.5",
|
||||||
"systemPrompt": "",
|
"systemPrompt": "",
|
||||||
"allowCustomChannel": true
|
"allowCustomChannel": true
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"allowRegister": true,
|
||||||
|
"linuxDo": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -25,18 +35,27 @@
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `modelChannel` | object | 模型渠道公开配置组 |
|
| `modelChannel` | object | 模型渠道公开配置组 |
|
||||||
|
| `auth` | object | 认证相关公开配置 |
|
||||||
|
|
||||||
`modelChannel` 字段:
|
`modelChannel` 字段:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 |
|
| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 |
|
||||||
|
| `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 |
|
||||||
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 |
|
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 |
|
||||||
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
|
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
|
||||||
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
|
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
|
||||||
| `systemPrompt` | string | 系统提示词 |
|
| `systemPrompt` | string | 系统提示词 |
|
||||||
| `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 |
|
| `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 |
|
||||||
|
|
||||||
|
`modelCosts` 每项字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `model` | string | 模型名称 |
|
||||||
|
| `credits` | number | 每次后端模型接口调用前预扣的算力点 |
|
||||||
|
|
||||||
用户侧请求模式:
|
用户侧请求模式:
|
||||||
|
|
||||||
| 模式 | 说明 |
|
| 模式 | 说明 |
|
||||||
@@ -44,6 +63,13 @@
|
|||||||
| 云端渠道 | 使用后端 `/api/v1/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
|
| 云端渠道 | 使用后端 `/api/v1/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
|
||||||
| 本地直连 | 默认可选;`allowCustomChannel` 关闭后不可选,用户在浏览器本地配置 `baseUrl`、`apiKey` 和模型列表后直接请求模型接口 |
|
| 本地直连 | 默认可选;`allowCustomChannel` 关闭后不可选,用户在浏览器本地配置 `baseUrl`、`apiKey` 和模型列表后直接请求模型接口 |
|
||||||
|
|
||||||
|
`auth` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `allowRegister` | boolean | 是否允许用户注册,默认允许;关闭后注册入口隐藏,注册接口拒绝新用户创建 |
|
||||||
|
| `linuxDo.enabled` | boolean | 是否开启 Linux.do 登录 |
|
||||||
|
|
||||||
## private.value
|
## private.value
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -59,13 +85,18 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"remark": ""
|
"remark": ""
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"promptSync": {
|
||||||
|
"enabled": true,
|
||||||
|
"cron": "*/5 * * * *"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `channels` | object[] | 模型渠道列表 |
|
| `channels` | object[] | 模型渠道列表 |
|
||||||
|
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||||
|
|
||||||
`channels` 每项字段:
|
`channels` 每项字段:
|
||||||
|
|
||||||
@@ -81,3 +112,10 @@
|
|||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
|
||||||
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
||||||
|
|
||||||
|
`promptSync` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | boolean | 是否开启定时同步,默认开启 |
|
||||||
|
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||||
| https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 |
|
| https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 |
|
||||||
|
| https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts | 已实现同步逻辑 |
|
||||||
|
|||||||
@@ -1,45 +1,3 @@
|
|||||||
# TODO
|
# 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 体验和工程
|
|
||||||
|
|
||||||
- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type adminSyncRequest struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminBatchDeleteRequest struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
OK(w, service.ListPromptCategories())
|
OK(w, service.ListPromptCategories())
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
|
|||||||
OK(w, true)
|
OK(w, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminDeletePrompts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request adminBatchDeleteRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
|
if err := service.DeletePrompts(request.IDs); err != nil {
|
||||||
|
FailError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(w, true)
|
||||||
|
}
|
||||||
|
|
||||||
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
var request adminSyncRequest
|
var request adminSyncRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
@@ -25,6 +26,38 @@ func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxyAIRequest(w, r, "/chat/completions")
|
proxyAIRequest(w, r, "/chat/completions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
|
||||||
|
Fail(w, "AI 接口请求失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) {
|
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
body, contentType, modelName, err := readAIRequest(r)
|
body, contentType, modelName, err := readAIRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,6 +65,18 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
|||||||
Fail(w, "AI 接口请求失败")
|
Fail(w, "AI 接口请求失败")
|
||||||
return
|
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)
|
channel, err := service.SelectModelChannel(modelName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
|
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
|
||||||
@@ -48,9 +93,24 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
request.Header.Set("Content-Type", 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)
|
response, err := http.DefaultClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
|
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
|
||||||
|
if onFailure != nil {
|
||||||
|
onFailure()
|
||||||
|
}
|
||||||
Fail(w, "AI 接口请求失败")
|
Fail(w, "AI 接口请求失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,6 +119,9 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
|||||||
if response.StatusCode >= http.StatusBadRequest {
|
if response.StatusCode >= http.StatusBadRequest {
|
||||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
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)))
|
log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload)))
|
||||||
|
if onFailure != nil {
|
||||||
|
onFailure()
|
||||||
|
}
|
||||||
Fail(w, "AI 接口请求失败")
|
Fail(w, "AI 接口请求失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,6 +177,34 @@ func readMultipartModel(body []byte, contentType string) string {
|
|||||||
return ""
|
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{"缺少模型名称"}
|
var errMissingModel = &aiError{"缺少模型名称"}
|
||||||
|
|
||||||
type aiError struct {
|
type aiError struct {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/basketikun/infinite-canvas/model"
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
"github.com/basketikun/infinite-canvas/service"
|
"github.com/basketikun/infinite-canvas/service"
|
||||||
@@ -22,7 +24,14 @@ type saveUserRequest struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
Role model.UserRole `json:"role"`
|
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) {
|
func Register(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -44,11 +53,25 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
FailError(w, err)
|
FailError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if session.User.Role != model.UserRoleAdmin {
|
OK(w, session)
|
||||||
Fail(w, "需要管理员权限")
|
}
|
||||||
|
|
||||||
|
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
|
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) {
|
func AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -89,7 +112,10 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := service.SaveUser(model.User{
|
user, err := service.SaveUser(model.User{
|
||||||
ID: request.ID,
|
ID: request.ID,
|
||||||
Username: request.Username,
|
Username: request.Username,
|
||||||
|
Email: request.Email,
|
||||||
|
DisplayName: request.DisplayName,
|
||||||
Role: request.Role,
|
Role: request.Role,
|
||||||
|
Status: request.Status,
|
||||||
}, request.Password)
|
}, request.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
FailError(w, err)
|
FailError(w, err)
|
||||||
@@ -98,6 +124,59 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
OK(w, user)
|
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) {
|
func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
if err := service.DeleteUser(id); err != nil {
|
if err := service.DeleteUser(id); err != nil {
|
||||||
FailError(w, err)
|
FailError(w, err)
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ func main() {
|
|||||||
if err := service.EnsureDefaultAdmin(); err != nil {
|
if err := service.EnsureDefaultAdmin(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
service.StartPromptSyncScheduler()
|
||||||
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ func AdminAuth(c *gin.Context) {
|
|||||||
c.Next()
|
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) {
|
func OptionalAuth(c *gin.Context) {
|
||||||
if user, ok := authUser(c); ok {
|
if user, ok := authUser(c); ok {
|
||||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ type ModelChannel struct {
|
|||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelCost 模型算力点配置。
|
||||||
|
type ModelCost struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Credits int `json:"credits"`
|
||||||
|
}
|
||||||
|
|
||||||
// PublicModelChannelSetting 公开模型渠道配置。
|
// PublicModelChannelSetting 公开模型渠道配置。
|
||||||
type PublicModelChannelSetting struct {
|
type PublicModelChannelSetting struct {
|
||||||
AvailableModels []string `json:"availableModels"`
|
AvailableModels []string `json:"availableModels"`
|
||||||
|
ModelCosts []ModelCost `json:"modelCosts"`
|
||||||
DefaultModel string `json:"defaultModel"`
|
DefaultModel string `json:"defaultModel"`
|
||||||
DefaultImageModel string `json:"defaultImageModel"`
|
DefaultImageModel string `json:"defaultImageModel"`
|
||||||
|
DefaultVideoModel string `json:"defaultVideoModel"`
|
||||||
DefaultTextModel string `json:"defaultTextModel"`
|
DefaultTextModel string `json:"defaultTextModel"`
|
||||||
SystemPrompt string `json:"systemPrompt"`
|
SystemPrompt string `json:"systemPrompt"`
|
||||||
AllowCustomChannel *bool `json:"allowCustomChannel"`
|
AllowCustomChannel *bool `json:"allowCustomChannel"`
|
||||||
@@ -34,11 +42,38 @@ type PublicModelChannelSetting struct {
|
|||||||
// PublicSetting 公开配置。
|
// PublicSetting 公开配置。
|
||||||
type PublicSetting struct {
|
type PublicSetting struct {
|
||||||
ModelChannel PublicModelChannelSetting `json:"modelChannel"`
|
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 私有配置。
|
// PrivateSetting 私有配置。
|
||||||
type PrivateSetting struct {
|
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 系统配置。
|
// Setting 系统配置。
|
||||||
|
|||||||
@@ -8,12 +8,32 @@ const (
|
|||||||
UserRoleAdmin UserRole = "admin"
|
UserRoleAdmin UserRole = "admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UserStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserStatusActive UserStatus = "active"
|
||||||
|
UserStatusBan UserStatus = "ban"
|
||||||
|
)
|
||||||
|
|
||||||
// User 系统用户。
|
// User 系统用户。
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
ID string `json:"id" gorm:"primaryKey"`
|
||||||
Username string `json:"username" gorm:"uniqueIndex"`
|
Username string `json:"username" gorm:"uniqueIndex"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
Role UserRole `json:"role"`
|
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"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -28,7 +48,10 @@ type UserList struct {
|
|||||||
type AuthUser struct {
|
type AuthUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
Role UserRole `json:"role"`
|
Role UserRole `json:"role"`
|
||||||
|
Credits int `json:"credits"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -43,8 +66,37 @@ func PublicUser(user User) AuthUser {
|
|||||||
return AuthUser{
|
return AuthUser{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
|
Credits: user.Credits,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
UpdatedAt: user.UpdatedAt,
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var promptCategories = []model.PromptCategory{
|
|||||||
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
|
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
|
||||||
{Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true},
|
{Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true},
|
||||||
{Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true},
|
{Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true},
|
||||||
|
{Category: "davidwu-gpt-image2-prompts", Name: "awesome-gpt-image2-prompts", Description: "davidwuw0811-boop 整理的 GPT Image 2 提示词分类", GithubURL: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -46,6 +47,7 @@ func DB() (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
dbErr = db.AutoMigrate(
|
dbErr = db.AutoMigrate(
|
||||||
&model.User{},
|
&model.User{},
|
||||||
|
&model.CreditLog{},
|
||||||
&model.Prompt{},
|
&model.Prompt{},
|
||||||
&model.Asset{},
|
&model.Asset{},
|
||||||
&model.Setting{},
|
&model.Setting{},
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ func DeletePrompt(id string) error {
|
|||||||
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePrompts 批量删除提示词。
|
||||||
|
func DeletePrompts(ids []string) error {
|
||||||
|
db, err := DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Delete(&model.Prompt{}, "id IN ?", ids).Error
|
||||||
|
}
|
||||||
|
|
||||||
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
||||||
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
||||||
db, err := DB()
|
db, err := DB()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/basketikun/infinite-canvas/model"
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -15,6 +16,10 @@ func ListUsers(q model.Query) ([]model.User, int64, error) {
|
|||||||
}
|
}
|
||||||
q.Normalize()
|
q.Normalize()
|
||||||
tx := db.Model(&model.User{})
|
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
|
var total int64
|
||||||
if err := tx.Count(&total).Error; err != nil {
|
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
|
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 删除指定用户。
|
// DeleteUser 删除指定用户。
|
||||||
func DeleteUser(id string) error {
|
func DeleteUser(id string) error {
|
||||||
db, err := DB()
|
db, err := DB()
|
||||||
@@ -83,6 +165,15 @@ func DeleteUser(id string) error {
|
|||||||
return db.Delete(&model.User{}, "id = ?", id).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。
|
// findUser 查询单个用户,并将未命中转换为 ok=false。
|
||||||
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
|
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
|
||||||
user := model.User{}
|
user := model.User{}
|
||||||
|
|||||||
@@ -18,11 +18,21 @@ func New() *gin.Engine {
|
|||||||
})
|
})
|
||||||
api.POST("/auth/register", gin.WrapF(handler.Register))
|
api.POST("/auth/register", gin.WrapF(handler.Register))
|
||||||
api.POST("/auth/login", gin.WrapF(handler.Login))
|
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("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
|
||||||
api.GET("/settings", gin.WrapF(handler.Settings))
|
api.GET("/settings", gin.WrapF(handler.Settings))
|
||||||
api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations))
|
v1 := api.Group("/v1", middleware.UserAuth)
|
||||||
api.POST("/v1/images/edits", gin.WrapF(handler.AIImagesEdits))
|
v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations))
|
||||||
api.POST("/v1/chat/completions", gin.WrapF(handler.AIChatCompletions))
|
v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits))
|
||||||
|
v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions))
|
||||||
|
v1.POST("/videos", gin.WrapF(handler.AIVideos))
|
||||||
|
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("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
|
||||||
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
|
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
|
||||||
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
|
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
|
||||||
@@ -30,9 +40,17 @@ func New() *gin.Engine {
|
|||||||
admin := api.Group("/admin", middleware.AdminAuth)
|
admin := api.Group("/admin", middleware.AdminAuth)
|
||||||
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
||||||
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
|
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) {
|
admin.DELETE("/users/:id", func(c *gin.Context) {
|
||||||
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
|
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.GET("/settings", gin.WrapF(handler.AdminSettings))
|
||||||
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
|
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
|
||||||
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
|
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
|
||||||
@@ -41,6 +59,7 @@ func New() *gin.Engine {
|
|||||||
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
||||||
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
||||||
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
||||||
|
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
|
||||||
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
||||||
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +28,10 @@ type TokenClaims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userExtra struct {
|
||||||
|
LinuxDo any `json:"linuxDo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func EnsureDefaultAdmin() error {
|
func EnsureDefaultAdmin() error {
|
||||||
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -39,6 +50,8 @@ func EnsureDefaultAdmin() error {
|
|||||||
Username: strings.TrimSpace(config.Cfg.AdminUsername),
|
Username: strings.TrimSpace(config.Cfg.AdminUsername),
|
||||||
Password: hash,
|
Password: hash,
|
||||||
Role: model.UserRoleAdmin,
|
Role: model.UserRoleAdmin,
|
||||||
|
AffCode: newAffCode(),
|
||||||
|
Status: model.UserStatusActive,
|
||||||
CreatedAt: now(),
|
CreatedAt: now(),
|
||||||
UpdatedAt: now(),
|
UpdatedAt: now(),
|
||||||
})
|
})
|
||||||
@@ -46,19 +59,26 @@ func EnsureDefaultAdmin() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(username string, password string) (model.AuthSession, 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)
|
username = strings.TrimSpace(username)
|
||||||
if strings.ContainsAny(username, " \t\r\n") {
|
if strings.ContainsAny(username, " \t\r\n") {
|
||||||
return model.AuthSession{}, errors.New("用户名不能包含空格")
|
return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
|
||||||
}
|
}
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
return model.AuthSession{}, errors.New("用户名和密码不能为空")
|
return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
|
||||||
}
|
}
|
||||||
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
|
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.AuthSession{}, err
|
return model.AuthSession{}, err
|
||||||
}
|
}
|
||||||
return model.AuthSession{}, errors.New("用户名已存在")
|
return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
|
||||||
}
|
}
|
||||||
hash, err := hashPassword(password)
|
hash, err := hashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,6 +89,8 @@ func Register(username string, password string) (model.AuthSession, error) {
|
|||||||
Username: username,
|
Username: username,
|
||||||
Password: hash,
|
Password: hash,
|
||||||
Role: model.UserRoleUser,
|
Role: model.UserRoleUser,
|
||||||
|
AffCode: newAffCode(),
|
||||||
|
Status: model.UserStatusActive,
|
||||||
CreatedAt: now(),
|
CreatedAt: now(),
|
||||||
UpdatedAt: now(),
|
UpdatedAt: now(),
|
||||||
})
|
})
|
||||||
@@ -84,11 +106,102 @@ func Login(username string, password string) (model.AuthSession, error) {
|
|||||||
return model.AuthSession{}, err
|
return model.AuthSession{}, err
|
||||||
}
|
}
|
||||||
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
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)
|
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) {
|
func ParseToken(tokenText string) (TokenClaims, error) {
|
||||||
claims := TokenClaims{}
|
claims := TokenClaims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
|
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 {
|
if err != nil || !ok {
|
||||||
return model.AuthUser{}, false
|
return model.AuthUser{}, false
|
||||||
}
|
}
|
||||||
|
if user.Status == model.UserStatusBan {
|
||||||
|
return model.AuthUser{}, false
|
||||||
|
}
|
||||||
return model.PublicUser(user), true
|
return model.PublicUser(user), true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +238,7 @@ func ListUsers(q model.Query) (model.UserList, error) {
|
|||||||
}
|
}
|
||||||
for i := range users {
|
for i := range users {
|
||||||
users[i].Password = ""
|
users[i].Password = ""
|
||||||
|
normalizeUserDefaults(&users[i])
|
||||||
}
|
}
|
||||||
return model.UserList{Items: users, Total: int(total)}, nil
|
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) {
|
func SaveUser(user model.User, password string) (model.User, error) {
|
||||||
user.Username = strings.TrimSpace(user.Username)
|
user.Username = strings.TrimSpace(user.Username)
|
||||||
if strings.ContainsAny(user.Username, " \t\r\n") {
|
if strings.ContainsAny(user.Username, " \t\r\n") {
|
||||||
return user, errors.New("用户名不能包含空格")
|
return user, safeMessageError{message: "用户名不能包含空格"}
|
||||||
}
|
}
|
||||||
if user.Username == "" {
|
if user.Username == "" {
|
||||||
return user, errors.New("用户名不能为空")
|
return user, safeMessageError{message: "用户名不能为空"}
|
||||||
}
|
}
|
||||||
if user.Role == "" || user.Role == model.UserRoleGuest {
|
if user.Role == "" || user.Role == model.UserRoleGuest {
|
||||||
user.Role = model.UserRoleUser
|
user.Role = model.UserRoleUser
|
||||||
}
|
}
|
||||||
|
if user.Status == "" {
|
||||||
|
user.Status = model.UserStatusActive
|
||||||
|
}
|
||||||
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
|
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
} else if ok && saved.ID != user.ID {
|
} 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.ID = newID("user")
|
||||||
|
user.AffCode = newAffCode()
|
||||||
user.CreatedAt = now()
|
user.CreatedAt = now()
|
||||||
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
user.CreatedAt = saved.CreatedAt
|
user.CreatedAt = saved.CreatedAt
|
||||||
user.Password = saved.Password
|
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 != "" {
|
if password != "" {
|
||||||
hash, err := hashPassword(password)
|
hash, err := hashPassword(password)
|
||||||
@@ -158,8 +293,8 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
|||||||
}
|
}
|
||||||
user.Password = hash
|
user.Password = hash
|
||||||
}
|
}
|
||||||
if user.Password == "" {
|
if isCreate && user.Password == "" {
|
||||||
return user, errors.New("密码不能为空")
|
return user, safeMessageError{message: "密码不能为空"}
|
||||||
}
|
}
|
||||||
user.UpdatedAt = now()
|
user.UpdatedAt = now()
|
||||||
user, err := repository.SaveUser(user)
|
user, err := repository.SaveUser(user)
|
||||||
@@ -167,6 +302,103 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
|||||||
return user, err
|
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 {
|
func DeleteUser(id string) error {
|
||||||
return repository.DeleteUser(id)
|
return repository.DeleteUser(id)
|
||||||
}
|
}
|
||||||
@@ -214,6 +446,131 @@ func newID(prefix string) string {
|
|||||||
return prefix + "-" + uuid.NewString()
|
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() {
|
func WarnDefaultSecurityConfig() {
|
||||||
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
|
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")
|
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
|
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
|
||||||
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
|
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
|
||||||
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
|
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
|
||||||
|
davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"
|
||||||
)
|
)
|
||||||
|
|
||||||
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
|
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
|
||||||
@@ -34,6 +35,20 @@ type gptImage2Data struct {
|
|||||||
} `json:"records"`
|
} `json:"records"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type davidWuGptImage2Prompt struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TitleEN string `json:"title_en"`
|
||||||
|
TitleCN string `json:"title_cn"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
CategoryCN string `json:"category_cn"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
NeedsRef bool `json:"needs_ref"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
|
func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
|
||||||
for _, item := range repository.PromptCategories() {
|
for _, item := range repository.PromptCategories() {
|
||||||
if item.Category != category {
|
if item.Category != category {
|
||||||
@@ -63,6 +78,8 @@ func buildPromptCategory(category string) ([]model.Prompt, error) {
|
|||||||
return buildYouMindGptImage2Prompts()
|
return buildYouMindGptImage2Prompts()
|
||||||
case "youmind-nano-banana-pro":
|
case "youmind-nano-banana-pro":
|
||||||
return buildYouMindNanoBananaProPrompts()
|
return buildYouMindNanoBananaProPrompts()
|
||||||
|
case "davidwu-gpt-image2-prompts":
|
||||||
|
return buildDavidWuGptImage2Prompts()
|
||||||
}
|
}
|
||||||
return nil, errors.New("未知提示词分类")
|
return nil, errors.New("未知提示词分类")
|
||||||
}
|
}
|
||||||
@@ -173,6 +190,31 @@ func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
|
|||||||
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
|
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDavidWuGptImage2Prompts() ([]model.Prompt, error) {
|
||||||
|
raw, err := fetchText(davidWuGptImage2RawBase, "prompts.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data := []davidWuGptImage2Prompt{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []model.Prompt{}
|
||||||
|
for _, item := range data {
|
||||||
|
title := strings.TrimSpace(item.TitleCN)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(item.TitleEN)
|
||||||
|
}
|
||||||
|
prompt := strings.TrimSpace(item.Prompt)
|
||||||
|
if title == "" || prompt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
image := absoluteImage(davidWuGptImage2RawBase, item.Image)
|
||||||
|
items = append(items, model.Prompt{ID: "davidwu-gpt-image2-prompts-" + leftPad(item.ID), Title: title, CoverURL: image, Prompt: prompt, Tags: davidWuGptImage2Tags(item), Preview: davidWuGptImage2Preview(item, image)})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
|
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
|
||||||
markdown, err := fetchText(baseURL, "README_zh.md")
|
markdown, err := fetchText(baseURL, "README_zh.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,6 +276,28 @@ func youMindTags(title, modelTag string) []string {
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func davidWuGptImage2Tags(item davidWuGptImage2Prompt) []string {
|
||||||
|
tags := splitTags(strings.Join([]string{item.CategoryCN, item.Category, item.Author, item.Source}, "/"), `/`)
|
||||||
|
if item.NeedsRef {
|
||||||
|
tags = append(tags, "需要参考图")
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func davidWuGptImage2Preview(item davidWuGptImage2Prompt, image string) string {
|
||||||
|
lines := []string{}
|
||||||
|
if item.TitleEN != "" {
|
||||||
|
lines = append(lines, item.TitleEN)
|
||||||
|
}
|
||||||
|
if item.Note != "" {
|
||||||
|
lines = append(lines, item.Note)
|
||||||
|
}
|
||||||
|
if image != "" {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
func splitTags(value string, pattern string) []string {
|
func splitTags(value string, pattern string) []string {
|
||||||
tags := []string{}
|
tags := []string{}
|
||||||
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
|
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
|
"github.com/basketikun/infinite-canvas/repository"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPromptSyncCron = "*/5 * * * *"
|
||||||
|
|
||||||
|
var (
|
||||||
|
promptSyncCron *cron.Cron
|
||||||
|
promptSyncOnce sync.Once
|
||||||
|
promptSyncMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartPromptSyncScheduler() {
|
||||||
|
promptSyncOnce.Do(func() {
|
||||||
|
promptSyncCron = cron.New()
|
||||||
|
promptSyncCron.Start()
|
||||||
|
})
|
||||||
|
RefreshPromptSyncScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshPromptSyncScheduler() {
|
||||||
|
promptSyncMu.Lock()
|
||||||
|
defer promptSyncMu.Unlock()
|
||||||
|
if promptSyncCron == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range promptSyncCron.Entries() {
|
||||||
|
promptSyncCron.Remove(entry.ID)
|
||||||
|
}
|
||||||
|
settings, err := repository.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("load prompt sync setting failed err=%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
|
||||||
|
if setting.Enabled == nil || !*setting.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
|
||||||
|
log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncRemotePromptCategories() {
|
||||||
|
for _, category := range repository.PromptCategories() {
|
||||||
|
if !category.Remote {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("scheduled prompt sync start category=%s", category.Category)
|
||||||
|
if _, err := SyncPromptCategory(category.Category); err != nil {
|
||||||
|
log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("scheduled prompt sync done category=%s", category.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting {
|
||||||
|
if setting.Cron == "" {
|
||||||
|
setting.Cron = defaultPromptSyncCron
|
||||||
|
}
|
||||||
|
if setting.Enabled == nil {
|
||||||
|
enabled := true
|
||||||
|
setting.Enabled = &enabled
|
||||||
|
}
|
||||||
|
return setting
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ func DeletePrompt(id string) error {
|
|||||||
return repository.DeletePrompt(id)
|
return repository.DeletePrompt(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeletePrompts(ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repository.DeletePrompts(ids)
|
||||||
|
}
|
||||||
|
|
||||||
func promptCategoryCodes(items []model.PromptCategory) []string {
|
func promptCategoryCodes(items []model.PromptCategory) []string {
|
||||||
codes := []string{}
|
codes := []string{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
|
|||||||
}
|
}
|
||||||
settings = normalizeSettings(settings)
|
settings = normalizeSettings(settings)
|
||||||
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
||||||
|
keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
|
||||||
result, err := repository.SaveSettings(settings, now())
|
result, err := repository.SaveSettings(settings, now())
|
||||||
|
if err == nil {
|
||||||
|
RefreshPromptSyncScheduler()
|
||||||
|
}
|
||||||
return hidePrivateAPIKeys(result), err
|
return hidePrivateAPIKeys(result), err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +65,45 @@ func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting {
|
|||||||
if setting.ModelChannel.AvailableModels == nil {
|
if setting.ModelChannel.AvailableModels == nil {
|
||||||
setting.ModelChannel.AvailableModels = []string{}
|
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 {
|
if setting.ModelChannel.AllowCustomChannel == nil {
|
||||||
enabled := true
|
enabled := true
|
||||||
setting.ModelChannel.AllowCustomChannel = &enabled
|
setting.ModelChannel.AllowCustomChannel = &enabled
|
||||||
}
|
}
|
||||||
|
if setting.Auth.AllowRegister == nil {
|
||||||
|
enabled := true
|
||||||
|
setting.Auth.AllowRegister = &enabled
|
||||||
|
}
|
||||||
return setting
|
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 {
|
func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting {
|
||||||
if setting.Channels == nil {
|
if setting.Channels == nil {
|
||||||
setting.Channels = []model.ModelChannel{}
|
setting.Channels = []model.ModelChannel{}
|
||||||
}
|
}
|
||||||
|
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
|
||||||
for i := range setting.Channels {
|
for i := range setting.Channels {
|
||||||
if setting.Channels[i].Protocol == "" {
|
if setting.Channels[i].Protocol == "" {
|
||||||
setting.Channels[i].Protocol = "openai"
|
setting.Channels[i].Protocol = "openai"
|
||||||
@@ -90,6 +122,7 @@ func hidePrivateAPIKeys(settings model.Settings) model.Settings {
|
|||||||
for i := range settings.Private.Channels {
|
for i := range settings.Private.Channels {
|
||||||
settings.Private.Channels[i].APIKey = ""
|
settings.Private.Channels[i].APIKey = ""
|
||||||
}
|
}
|
||||||
|
settings.Private.Auth.LinuxDo.ClientSecret = ""
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +137,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) {
|
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
|
||||||
for _, item := range saved {
|
for _, item := range saved {
|
||||||
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
|
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 255
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
bun.lock
|
||||||
|
*.log
|
||||||
|
*.ico
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.webp
|
||||||
|
*.svg
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 255,
|
||||||
|
"singleQuote": false,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1164,6 +1166,8 @@
|
|||||||
|
|
||||||
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
"powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||||
|
|
||||||
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
"pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||||
|
|
||||||
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>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 |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -23,6 +23,7 @@ export default function AdminAssetsPage() {
|
|||||||
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
|
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
|
||||||
const copyText = useCopyText();
|
const copyText = useCopyText();
|
||||||
const [form] = Form.useForm<AssetFormValues>();
|
const [form] = Form.useForm<AssetFormValues>();
|
||||||
|
const [keywordText, setKeywordText] = useState(keyword);
|
||||||
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
||||||
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
||||||
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
|
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
|
||||||
@@ -33,6 +34,8 @@ export default function AdminAssetsPage() {
|
|||||||
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
|
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
|
||||||
}, [editingAsset, form]);
|
}, [editingAsset, form]);
|
||||||
|
|
||||||
|
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||||
|
|
||||||
const saveAsset = async () => {
|
const saveAsset = async () => {
|
||||||
const value = await form.validateFields();
|
const value = await form.validateFields();
|
||||||
const nextType = value.type || "text";
|
const nextType = value.type || "text";
|
||||||
@@ -41,7 +44,10 @@ export default function AdminAssetsPage() {
|
|||||||
...value,
|
...value,
|
||||||
type: nextType,
|
type: nextType,
|
||||||
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
|
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
|
||||||
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
|
tags: (value.tagText || "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
});
|
});
|
||||||
setEditingAsset(null);
|
setEditingAsset(null);
|
||||||
};
|
};
|
||||||
@@ -57,7 +63,11 @@ export default function AdminAssetsPage() {
|
|||||||
title: "标题",
|
title: "标题",
|
||||||
dataIndex: "title",
|
dataIndex: "title",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
|
render: (_, item) => (
|
||||||
|
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
|
||||||
|
{item.title}
|
||||||
|
</Typography.Link>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "类型",
|
title: "类型",
|
||||||
@@ -69,7 +79,13 @@ export default function AdminAssetsPage() {
|
|||||||
title: "标签",
|
title: "标签",
|
||||||
dataIndex: "tags",
|
dataIndex: "tags",
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
|
render: (_, item) => (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "分类",
|
title: "分类",
|
||||||
@@ -84,9 +100,15 @@ export default function AdminAssetsPage() {
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (_, item) => (
|
render: (_, item) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip>
|
<Tooltip title="详情">
|
||||||
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip>
|
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
|
||||||
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} />
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -98,10 +120,38 @@ export default function AdminAssetsPage() {
|
|||||||
<Card variant="borderless">
|
<Card variant="borderless">
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Row gutter={16} align="bottom">
|
<Row gutter={16} align="bottom">
|
||||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
|
<Col flex="360px">
|
||||||
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
|
<Form.Item label="关键词">
|
||||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>查询</Button></Space></Form.Item></Col>
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="180px">
|
||||||
|
<Form.Item label="类型">
|
||||||
|
<Select value={kind} onChange={changeKind} options={typeOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="220px">
|
||||||
|
<Form.Item label="标签">
|
||||||
|
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="none">
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeywordText("");
|
||||||
|
resetFilters();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -114,22 +164,59 @@ export default function AdminAssetsPage() {
|
|||||||
defaultSize="middle"
|
defaultSize="middle"
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
cardProps={{ variant: "borderless" }}
|
cardProps={{ variant: "borderless" }}
|
||||||
headerTitle={<Space><Typography.Text strong>素材列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
headerTitle={
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>素材列表</Typography.Text>
|
||||||
|
<Tag>{total} 条</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
||||||
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>新增</Button>]}
|
toolBarRender={() => [
|
||||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
|
||||||
|
新增
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
showTotal: (value) => `共 ${value} 条`,
|
||||||
|
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||||
<Form form={form} layout="vertical" requiredMark={false}>
|
<Form form={form} layout="vertical" requiredMark={false}>
|
||||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
|
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
<Select options={editTypeOptions} />
|
||||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||||
<Form.Item name="category" label="分类"><Input /></Form.Item>
|
<Input />
|
||||||
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
|
</Form.Item>
|
||||||
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
|
<Form.Item name="coverUrl" label="封面 URL">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
{formType === "image" ? (
|
||||||
|
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||||
|
<Input.TextArea rows={6} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -139,18 +226,44 @@ export default function AdminAssetsPage() {
|
|||||||
<Flex gap={14} align="start">
|
<Flex gap={14} align="start">
|
||||||
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
|
{detailAsset.title}
|
||||||
|
</Typography.Title>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||||
|
{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}
|
||||||
|
{(detailAsset.tags || []).map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
|
{detailAsset.description ? (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||||
|
{detailAsset.description}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
|
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
|
||||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>复制内容</Button>
|
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
|
||||||
|
复制内容
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
<Modal
|
||||||
|
title="删除素材"
|
||||||
|
open={Boolean(deletingAsset)}
|
||||||
|
onCancel={() => setDeletingAsset(null)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!deletingAsset) return;
|
||||||
|
await deleteAsset(deletingAsset.id);
|
||||||
|
setDeletingAsset(null);
|
||||||
|
}}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。
|
确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。
|
||||||
</Modal>
|
</Modal>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Button, Flex, Layout, Menu, Typography, theme } from "antd";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -12,6 +12,8 @@ import { adminLayoutStyle } from "@/lib/app-theme";
|
|||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
const adminMenus = [
|
const adminMenus = [
|
||||||
|
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
|
||||||
|
{ key: "/admin/credit-logs", icon: <TransactionOutlined />, label: "算力点日志" },
|
||||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||||
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||||
@@ -25,8 +27,18 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
const user = useUserStore((state) => state.user);
|
const user = useUserStore((state) => state.user);
|
||||||
const isReady = useUserStore((state) => state.isReady);
|
const isReady = useUserStore((state) => state.isReady);
|
||||||
const logout = useUserStore((state) => state.clearSession);
|
const logout = useUserStore((state) => state.clearSession);
|
||||||
const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
|
const activeKey = pathname.startsWith("/admin/settings")
|
||||||
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
|
? "/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(() => {
|
useEffect(() => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
@@ -52,22 +64,40 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||||
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
||||||
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||||
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>无限画布</Typography.Text>
|
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
|
||||||
|
无限画布
|
||||||
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[activeKey]}
|
selectedKeys={[activeKey]}
|
||||||
style={adminLayoutStyle.menu}
|
style={adminLayoutStyle.menu}
|
||||||
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))}
|
items={adminMenus.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: (
|
||||||
|
<Link href={item.key} style={{ color: "inherit" }}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
style: adminLayoutStyle.menuItem,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
||||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">前往画布</Button>
|
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
|
||||||
<Button block icon={<LogoutOutlined />} onClick={logout}>退出登录</Button>
|
前往画布
|
||||||
|
</Button>
|
||||||
|
<Button block icon={<LogoutOutlined />} onClick={logout}>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
<Layout style={{ background: antToken.colorBgLayout }}>
|
<Layout style={{ background: antToken.colorBgLayout }}>
|
||||||
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}>
|
<Layout.Header
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
|
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{pageTitle}
|
||||||
|
</Typography.Title>
|
||||||
<Flex align="center" gap={4}>
|
<Flex align="center" gap={4}>
|
||||||
<UserStatusActions showConfig={false} />
|
<UserStatusActions showConfig={false} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
redirect("/admin/assets");
|
redirect("/admin/users");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,38 @@ import type { Prompt } from "@/services/api/prompts";
|
|||||||
import { useAdminPrompts } from "./use-admin-prompts";
|
import { useAdminPrompts } from "./use-admin-prompts";
|
||||||
|
|
||||||
export default function AdminPromptsPage() {
|
export default function AdminPromptsPage() {
|
||||||
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts();
|
const {
|
||||||
|
categories,
|
||||||
|
prompts,
|
||||||
|
tags,
|
||||||
|
keyword,
|
||||||
|
category,
|
||||||
|
tag,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
isSyncing,
|
||||||
|
searchPrompts,
|
||||||
|
changeCategory,
|
||||||
|
changeTag,
|
||||||
|
changePage,
|
||||||
|
changePageSize,
|
||||||
|
resetFilters,
|
||||||
|
refreshPrompts,
|
||||||
|
syncCategory,
|
||||||
|
savePrompt: saveAdminPrompt,
|
||||||
|
deletePrompt,
|
||||||
|
deletePrompts,
|
||||||
|
} = useAdminPrompts();
|
||||||
const copyText = useCopyText();
|
const copyText = useCopyText();
|
||||||
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
||||||
|
const [keywordText, setKeywordText] = useState(keyword);
|
||||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||||
const [deletingPrompt, setDeletingPrompt] = 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 [isSyncOpen, setIsSyncOpen] = useState(false);
|
||||||
const defaultCategory = categories[0]?.category || "";
|
const defaultCategory = categories[0]?.category || "";
|
||||||
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
||||||
@@ -26,12 +52,28 @@ export default function AdminPromptsPage() {
|
|||||||
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
|
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
|
||||||
}, [editingPrompt, form]);
|
}, [editingPrompt, form]);
|
||||||
|
|
||||||
|
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||||
|
|
||||||
const savePrompt = async () => {
|
const savePrompt = async () => {
|
||||||
const value = await form.validateFields();
|
const value = await form.validateFields();
|
||||||
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
|
await saveAdminPrompt({
|
||||||
|
...editingPrompt,
|
||||||
|
...value,
|
||||||
|
category: value.category || defaultCategory,
|
||||||
|
tags: (value.tagText || "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
setEditingPrompt(null);
|
setEditingPrompt(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const batchDeletePrompts = async () => {
|
||||||
|
await deletePrompts(selectedPromptIds);
|
||||||
|
setSelectedPromptIds([]);
|
||||||
|
setIsBatchDeleteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<Prompt>[] = [
|
const columns: ProColumns<Prompt>[] = [
|
||||||
{
|
{
|
||||||
title: "封面",
|
title: "封面",
|
||||||
@@ -43,7 +85,11 @@ export default function AdminPromptsPage() {
|
|||||||
title: "标题",
|
title: "标题",
|
||||||
dataIndex: "title",
|
dataIndex: "title",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
|
render: (_, item) => (
|
||||||
|
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
|
||||||
|
{item.title}
|
||||||
|
</Typography.Link>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "分类",
|
title: "分类",
|
||||||
@@ -55,7 +101,13 @@ export default function AdminPromptsPage() {
|
|||||||
title: "标签",
|
title: "标签",
|
||||||
dataIndex: "tags",
|
dataIndex: "tags",
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
|
render: (_, item) => (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
@@ -64,9 +116,15 @@ export default function AdminPromptsPage() {
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (_, item) => (
|
render: (_, item) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
|
<Tooltip title="详情">
|
||||||
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
|
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
|
||||||
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} />
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -78,10 +136,38 @@ export default function AdminPromptsPage() {
|
|||||||
<Card variant="borderless">
|
<Card variant="borderless">
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Row gutter={16} align="bottom">
|
<Row gutter={16} align="bottom">
|
||||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
|
<Col flex="360px">
|
||||||
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
|
<Form.Item label="关键词">
|
||||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
<Input.Search value={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>查询</Button></Space></Form.Item></Col>
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="220px">
|
||||||
|
<Form.Item label="分组">
|
||||||
|
<Select value={category} onChange={changeCategory} options={categoryOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="220px">
|
||||||
|
<Form.Item label="标签">
|
||||||
|
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col flex="none">
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeywordText("");
|
||||||
|
resetFilters();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -94,20 +180,54 @@ export default function AdminPromptsPage() {
|
|||||||
defaultSize="middle"
|
defaultSize="middle"
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
cardProps={{ variant: "borderless" }}
|
cardProps={{ variant: "borderless" }}
|
||||||
headerTitle={<Space><Typography.Text strong>提示词列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
headerTitle={
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>提示词列表</Typography.Text>
|
||||||
|
<Tag>{total} 条</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
||||||
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>同步</Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>新增</Button>]}
|
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
toolBarRender={() => [
|
||||||
|
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
|
||||||
|
批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
|
||||||
|
</Button>,
|
||||||
|
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
|
||||||
|
同步
|
||||||
|
</Button>,
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>
|
||||||
|
新增
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
showTotal: (value) => `共 ${value} 条`,
|
||||||
|
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||||
<Form form={form} layout="vertical" requiredMark={false}>
|
<Form form={form} layout="vertical" requiredMark={false}>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||||
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
|
<Input />
|
||||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
<Form.Item name="category" label="分类">
|
||||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
|
<Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="coverUrl" label="封面 URL">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
|
||||||
|
<Input.TextArea rows={6} />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -117,35 +237,111 @@ export default function AdminPromptsPage() {
|
|||||||
<Flex gap={14} align="start">
|
<Flex gap={14} align="start">
|
||||||
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
|
{detailPrompt.title}
|
||||||
|
</Typography.Title>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag>{categoryName(detailPrompt.category)}</Tag>
|
||||||
|
{(detailPrompt.tags || []).map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
|
{detailPrompt.preview ? (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||||
|
{detailPrompt.preview}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
|
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>复制提示词</Button>
|
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
|
||||||
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">远程源</Button> : null}
|
复制提示词
|
||||||
|
</Button>
|
||||||
|
{detailPrompt.githubUrl ? (
|
||||||
|
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
|
||||||
|
远程源
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>取消</Button>}>
|
<Modal
|
||||||
|
title="同步远程提示词源"
|
||||||
|
open={isSyncOpen}
|
||||||
|
width={640}
|
||||||
|
onCancel={() => !isSyncing && setIsSyncOpen(false)}
|
||||||
|
mask={{ closable: !isSyncing }}
|
||||||
|
footer={
|
||||||
|
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Table
|
<Table
|
||||||
rowKey="category"
|
rowKey="category"
|
||||||
dataSource={categories.filter((item) => item.remote)}
|
dataSource={categories.filter((item) => item.remote)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "远程源", dataIndex: "name", render: (_, item) => <Flex align="center" gap={8}>{item.name}{item.githubUrl ? <Typography.Link href={item.githubUrl} target="_blank"><ExportOutlined /></Typography.Link> : null}</Flex> },
|
{
|
||||||
{ title: "", key: "sync", width: 96, align: "right", render: (_, item) => <Button type="primary" loading={isSyncing} onClick={async () => { try { await syncCategory(item.category); setIsSyncOpen(false); } catch {} }}>同步</Button> },
|
title: "远程源",
|
||||||
|
dataIndex: "name",
|
||||||
|
render: (_, item) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{item.name}
|
||||||
|
{item.githubUrl ? (
|
||||||
|
<Typography.Link href={item.githubUrl} target="_blank">
|
||||||
|
<ExportOutlined />
|
||||||
|
</Typography.Link>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "sync",
|
||||||
|
width: 96,
|
||||||
|
align: "right",
|
||||||
|
render: (_, item) => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={isSyncing}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await syncCategory(item.category);
|
||||||
|
setIsSyncOpen(false);
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
<Modal
|
||||||
|
title="删除提示词"
|
||||||
|
open={Boolean(deletingPrompt)}
|
||||||
|
onCancel={() => setDeletingPrompt(null)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!deletingPrompt) return;
|
||||||
|
await deletePrompt(deletingPrompt.id);
|
||||||
|
setDeletingPrompt(null);
|
||||||
|
}}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||||
|
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||||
|
</Modal>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { App } from "antd";
|
import { App } from "antd";
|
||||||
|
|
||||||
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||||
import type { Prompt } from "@/services/api/prompts";
|
import type { Prompt } from "@/services/api/prompts";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
@@ -71,6 +71,18 @@ export function useAdminPrompts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const batchDeleteMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||||
|
message.success("提示词已批量删除");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(error instanceof Error ? error.message : "批量删除失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const error = categoriesQuery.error || promptsQuery.error;
|
const error = categoriesQuery.error || promptsQuery.error;
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
@@ -101,7 +113,7 @@ export function useAdminPrompts() {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total || 0,
|
total: data?.total || 0,
|
||||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||||
isSyncing: syncMutation.isPending,
|
isSyncing: syncMutation.isPending,
|
||||||
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
||||||
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
||||||
@@ -116,5 +128,6 @@ export function useAdminPrompts() {
|
|||||||
},
|
},
|
||||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||||
|
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { Alert, App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
|
import { App, Button, Card, Checkbox, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { EditorView } from "@uiw/react-codemirror";
|
import { EditorView } from "@uiw/react-codemirror";
|
||||||
|
|
||||||
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminSettings } from "@/services/api/admin";
|
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminModelCost, type AdminSettings } from "@/services/api/admin";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
|
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
|
||||||
@@ -28,19 +28,23 @@ const emptySettings: AdminSettings = {
|
|||||||
public: {
|
public: {
|
||||||
modelChannel: {
|
modelChannel: {
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
|
modelCosts: [],
|
||||||
defaultModel: "",
|
defaultModel: "",
|
||||||
defaultImageModel: "",
|
defaultImageModel: "",
|
||||||
|
defaultVideoModel: "",
|
||||||
defaultTextModel: "",
|
defaultTextModel: "",
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
allowCustomChannel: true,
|
allowCustomChannel: true,
|
||||||
},
|
},
|
||||||
|
auth: { allowRegister: true, linuxDo: { enabled: false } },
|
||||||
},
|
},
|
||||||
private: { channels: [] },
|
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
|
||||||
};
|
};
|
||||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
||||||
|
|
||||||
type SettingsTabKey = "public" | "private";
|
type SettingsTabKey = "public" | "private";
|
||||||
type EditorMode = "visual" | "json";
|
type EditorMode = "visual" | "json";
|
||||||
|
type ModelSelectTabKey = "new" | "current";
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const token = useUserStore((state) => state.token);
|
const token = useUserStore((state) => state.token);
|
||||||
@@ -58,14 +62,30 @@ export default function AdminSettingsPage() {
|
|||||||
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
||||||
const [testingModels, setTestingModels] = useState<string[]>([]);
|
const [testingModels, setTestingModels] = useState<string[]>([]);
|
||||||
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
|
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
|
||||||
|
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false);
|
||||||
|
const [modelSelectSource, setModelSelectSource] = useState<string[]>([]);
|
||||||
|
const [modelSelectExisting, setModelSelectExisting] = useState<string[]>([]);
|
||||||
|
const [modelSelectSelected, setModelSelectSelected] = useState<string[]>([]);
|
||||||
|
const [modelSelectKeyword, setModelSelectKeyword] = useState("");
|
||||||
|
const [modelSelectNewModel, setModelSelectNewModel] = useState("");
|
||||||
|
const [modelSelectTab, setModelSelectTab] = useState<ModelSelectTabKey>("new");
|
||||||
|
const [isFetchingChannelModels, setIsFetchingChannelModels] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
|
||||||
|
const [knownModels, setKnownModels] = useState<string[]>([]);
|
||||||
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
||||||
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
||||||
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
|
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
|
||||||
const activeMode = editorMode[activeTab];
|
const activeMode = editorMode[activeTab];
|
||||||
const activeJsonText = jsonText[activeTab];
|
const activeJsonText = jsonText[activeTab];
|
||||||
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
|
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
|
||||||
|
const modelSelectGroups = useMemo(() => buildModelSelectGroups(modelSelectSource, modelSelectExisting), [modelSelectSource, modelSelectExisting]);
|
||||||
|
const activeModelSelectModels = useMemo(() => {
|
||||||
|
const keyword = modelSelectKeyword.trim().toLowerCase();
|
||||||
|
return modelSelectGroups[modelSelectTab].filter((model) => model.toLowerCase().includes(keyword));
|
||||||
|
}, [modelSelectGroups, modelSelectKeyword, modelSelectTab]);
|
||||||
|
const activeSelectedCount = activeModelSelectModels.filter((model) => modelSelectSelected.includes(model)).length;
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -74,6 +94,8 @@ export default function AdminSettingsPage() {
|
|||||||
const data = normalizeSettings(await fetchAdminSettings(token));
|
const data = normalizeSettings(await fetchAdminSettings(token));
|
||||||
form.setFieldsValue(data);
|
form.setFieldsValue(data);
|
||||||
setChannels(data.private.channels);
|
setChannels(data.private.channels);
|
||||||
|
setModelCosts(data.public.modelChannel.modelCosts);
|
||||||
|
setKnownModels(collectKnownModels(data));
|
||||||
setJsonText({
|
setJsonText({
|
||||||
public: JSON.stringify(data.public, null, 2),
|
public: JSON.stringify(data.public, null, 2),
|
||||||
private: JSON.stringify(data.private, null, 2),
|
private: JSON.stringify(data.private, null, 2),
|
||||||
@@ -105,6 +127,8 @@ export default function AdminSettingsPage() {
|
|||||||
const merged = mergeChannelApiKeys(values.private.channels, saved);
|
const merged = mergeChannelApiKeys(values.private.channels, saved);
|
||||||
form.setFieldsValue(merged);
|
form.setFieldsValue(merged);
|
||||||
setChannels(merged.private.channels);
|
setChannels(merged.private.channels);
|
||||||
|
setModelCosts(merged.public.modelChannel.modelCosts);
|
||||||
|
rememberKnownModels(merged);
|
||||||
setJsonText({
|
setJsonText({
|
||||||
public: JSON.stringify(merged.public, null, 2),
|
public: JSON.stringify(merged.public, null, 2),
|
||||||
private: JSON.stringify(merged.private, null, 2),
|
private: JSON.stringify(merged.private, null, 2),
|
||||||
@@ -133,6 +157,8 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
|
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
|
||||||
if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels);
|
if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels);
|
||||||
|
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
|
||||||
|
rememberKnownModels({ ...normalizeSettings(form.getFieldsValue(true) as AdminSettings), [tab]: parsed });
|
||||||
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
|
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,6 +168,7 @@ export default function AdminSettingsPage() {
|
|||||||
message.error("JSON 格式不正确");
|
message.error("JSON 格式不正确");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
|
||||||
setJsonText((current) => ({
|
setJsonText((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[tab]: JSON.stringify(parsed, null, 2),
|
[tab]: JSON.stringify(parsed, null, 2),
|
||||||
@@ -151,7 +178,9 @@ export default function AdminSettingsPage() {
|
|||||||
const openChannelDrawer = (index: number | null) => {
|
const openChannelDrawer = (index: number | null) => {
|
||||||
setEditingChannelIndex(index);
|
setEditingChannelIndex(index);
|
||||||
setIsChannelDrawerOpen(true);
|
setIsChannelDrawerOpen(true);
|
||||||
channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index]));
|
const channel = index === null ? emptyChannel : normalizeChannel(channels[index]);
|
||||||
|
channelForm.setFieldsValue(channel);
|
||||||
|
rememberModels(channel.models);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeChannelDrawer = () => {
|
const closeChannelDrawer = () => {
|
||||||
@@ -162,6 +191,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
const saveChannel = async () => {
|
const saveChannel = async () => {
|
||||||
const channel = normalizeChannel(await channelForm.validateFields());
|
const channel = normalizeChannel(await channelForm.validateFields());
|
||||||
|
rememberModels(channel.models);
|
||||||
const nextChannels = [...channels];
|
const nextChannels = [...channels];
|
||||||
if (editingChannelIndex === null) nextChannels.push(channel);
|
if (editingChannelIndex === null) nextChannels.push(channel);
|
||||||
else nextChannels[editingChannelIndex] = channel;
|
else nextChannels[editingChannelIndex] = channel;
|
||||||
@@ -180,15 +210,81 @@ export default function AdminSettingsPage() {
|
|||||||
message.warning("请先填写 API Key");
|
message.warning("请先填写 API Key");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setIsFetchingChannelModels(true);
|
||||||
try {
|
try {
|
||||||
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
|
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
|
||||||
channelForm.setFieldValue("models", channelModels);
|
const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []);
|
||||||
message.success(`已获取 ${channelModels.length} 个模型`);
|
rememberModels(channelModels);
|
||||||
|
setModelSelectExisting(current);
|
||||||
|
setModelSelectSource(uniqueModels(channelModels));
|
||||||
|
setModelSelectSelected(uniqueModels([...current, ...channelModels]));
|
||||||
|
setModelSelectKeyword("");
|
||||||
|
setModelSelectNewModel("");
|
||||||
|
setModelSelectTab("new");
|
||||||
|
setIsModelSelectorOpen(true);
|
||||||
|
message.success(`已获取 ${channelModels.length} 个模型,请选择后确认`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||||
|
} finally {
|
||||||
|
setIsFetchingChannelModels(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openChannelModelSelector = (sourceModels?: string[]) => {
|
||||||
|
const current = uniqueModels(channelForm.getFieldValue("models") || []);
|
||||||
|
const source = uniqueModels(sourceModels !== undefined ? sourceModels : [...knownModels, ...current]);
|
||||||
|
setModelSelectExisting(current);
|
||||||
|
setModelSelectSource(source);
|
||||||
|
setModelSelectSelected(sourceModels ? uniqueModels([...current, ...source]) : current);
|
||||||
|
setModelSelectKeyword("");
|
||||||
|
setModelSelectNewModel("");
|
||||||
|
setModelSelectTab(sourceModels ? "new" : "current");
|
||||||
|
setIsModelSelectorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChannelModelSelector = () => {
|
||||||
|
setIsModelSelectorOpen(false);
|
||||||
|
setModelSelectKeyword("");
|
||||||
|
setModelSelectNewModel("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmChannelModelSelector = () => {
|
||||||
|
const models = uniqueModels(modelSelectSelected);
|
||||||
|
channelForm.setFieldValue("models", models);
|
||||||
|
rememberModels(models);
|
||||||
|
closeChannelModelSelector();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectedModel = (model: string, checked: boolean) => {
|
||||||
|
setModelSelectSelected((current) => (checked ? uniqueModels([...current, model]) : current.filter((item) => item !== model)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectActiveModels = () => {
|
||||||
|
setModelSelectSelected((current) => uniqueModels([...current, ...activeModelSelectModels]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearActiveModels = () => {
|
||||||
|
const active = new Set(activeModelSelectModels);
|
||||||
|
setModelSelectSelected((current) => current.filter((model) => !active.has(model)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addModelInSelector = () => {
|
||||||
|
const model = modelSelectNewModel.trim();
|
||||||
|
if (!model) return;
|
||||||
|
setModelSelectExisting((current) => uniqueModels([...current, model]));
|
||||||
|
setModelSelectSelected((current) => uniqueModels([...current, model]));
|
||||||
|
setModelSelectNewModel("");
|
||||||
|
setModelSelectTab("current");
|
||||||
|
};
|
||||||
|
|
||||||
|
function rememberModels(models: string[]) {
|
||||||
|
setKnownModels((current) => uniqueModels([...current, ...models]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberKnownModels(settings: AdminSettings) {
|
||||||
|
rememberModels(collectKnownModels(settings));
|
||||||
|
}
|
||||||
|
|
||||||
const openTestDialog = (index: number) => {
|
const openTestDialog = (index: number) => {
|
||||||
const channel = normalizeChannel(channels[index]);
|
const channel = normalizeChannel(channels[index]);
|
||||||
if (!channel.baseUrl || channel.models.length === 0) {
|
if (!channel.baseUrl || channel.models.length === 0) {
|
||||||
@@ -238,13 +334,17 @@ export default function AdminSettingsPage() {
|
|||||||
async function persistChannels(nextChannels: AdminModelChannel[]) {
|
async function persistChannels(nextChannels: AdminModelChannel[]) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
||||||
|
const nextChannelModels = collectChannelModels(nextChannels);
|
||||||
const nextSettings = normalizeSettings({
|
const nextSettings = normalizeSettings({
|
||||||
...values,
|
...values,
|
||||||
|
public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } },
|
||||||
private: { ...values.private, channels: nextChannels },
|
private: { ...values.private, channels: nextChannels },
|
||||||
});
|
});
|
||||||
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
|
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
|
||||||
const merged = mergeChannelApiKeys(nextChannels, saved);
|
const merged = mergeChannelApiKeys(nextChannels, saved);
|
||||||
setChannels(merged.private.channels);
|
setChannels(merged.private.channels);
|
||||||
|
setModelCosts(merged.public.modelChannel.modelCosts);
|
||||||
|
rememberKnownModels(merged);
|
||||||
form.setFieldsValue(merged);
|
form.setFieldsValue(merged);
|
||||||
setJsonText({
|
setJsonText({
|
||||||
public: JSON.stringify(merged.public, null, 2),
|
public: JSON.stringify(merged.public, null, 2),
|
||||||
@@ -267,8 +367,12 @@ export default function AdminSettingsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>刷新</Button>
|
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>保存设置</Button>
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -278,12 +382,23 @@ export default function AdminSettingsPage() {
|
|||||||
<Segmented
|
<Segmented
|
||||||
value={activeMode}
|
value={activeMode}
|
||||||
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
|
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
|
||||||
options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]}
|
options={[
|
||||||
|
{ label: "可视化编辑", value: "visual" },
|
||||||
|
{ label: "手动编辑 JSON", value: "json" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
{activeMode === "json" ? (
|
{activeMode === "json" ? (
|
||||||
<Space>
|
<Space>
|
||||||
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON 格式正确</Tag>}
|
{jsonError ? (
|
||||||
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>格式化</Button>
|
<Tag color="error">{jsonError}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||||
|
JSON 格式正确
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>
|
||||||
|
格式化
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
|
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
|
||||||
@@ -294,12 +409,74 @@ export default function AdminSettingsPage() {
|
|||||||
activeMode === "visual" ? (
|
activeMode === "visual" ? (
|
||||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={24}><Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)"><Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
<Col span={24}>
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)" extra="可选项来自已启用渠道中选择的模型,最终开放哪些模型由这里勾选决定">
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
<Select mode="multiple" placeholder="请选择系统可用模型" options={channelModels.map((item) => ({ label: item, value: item }))} />
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
</Form.Item>
|
||||||
<Col span={24}><Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
|
</Col>
|
||||||
<Col span={24}><Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked"><Switch /></Form.Item></Col>
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
|
||||||
|
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
|
||||||
|
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "defaultVideoModel"]} label="默认视频模型">
|
||||||
|
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
|
||||||
|
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词">
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name={["public", "auth", "allowRegister"]} label="是否允许用户注册" extra="关闭后隐藏注册入口,注册接口也会拒绝新用户创建" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Typography.Title level={5}>模型算力点</Typography.Title>
|
||||||
|
<Table
|
||||||
|
rowKey="model"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
dataSource={publicModels.map((model) => ({ model, credits: modelCostCredits(modelCosts, model) }))}
|
||||||
|
columns={[
|
||||||
|
{ title: "模型", dataIndex: "model" },
|
||||||
|
{
|
||||||
|
title: "每次调用扣除",
|
||||||
|
dataIndex: "credits",
|
||||||
|
width: 220,
|
||||||
|
render: (_, item) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
precision={0}
|
||||||
|
className="!w-full"
|
||||||
|
value={item.credits}
|
||||||
|
addonAfter="点"
|
||||||
|
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
@@ -318,36 +495,99 @@ export default function AdminSettingsPage() {
|
|||||||
) : activeMode === "visual" ? (
|
) : activeMode === "visual" ? (
|
||||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
<Alert
|
<Card
|
||||||
showIcon
|
size="small"
|
||||||
type="warning"
|
title={
|
||||||
message="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
|
<Space>
|
||||||
/>
|
<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>新增渠道</Button>
|
Linux.do 登录
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={14}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
本项目接口回调地址是 /api/auth/linux-do/callback,请在 Linux.do 应用后台自行拼接站点前缀。
|
||||||
|
<Typography.Link href="https://connect.linux.do" target="_blank" rel="noreferrer">
|
||||||
|
点击此处管理你的 LinuxDO OAuth App
|
||||||
|
</Typography.Link>
|
||||||
|
</Typography.Text>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={9}>
|
||||||
|
<Form.Item name={["private", "auth", "linuxDo", "clientId"]} label="Linux.do Client ID">
|
||||||
|
<Input placeholder="输入 Linux.do OAuth App 的 ID" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={9}>
|
||||||
|
<Form.Item name={["private", "auth", "linuxDo", "clientSecret"]} label="Linux.do Client Secret">
|
||||||
|
<Input.Password placeholder="留空则沿用已保存的密钥" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="提示词定时同步">
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
|
||||||
|
<Input placeholder="*/5 * * * *" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>
|
||||||
|
新增渠道
|
||||||
|
</Button>
|
||||||
<Table
|
<Table
|
||||||
rowKey={(_, index) => String(index)}
|
rowKey="_rowKey"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={channels}
|
dataSource={channelTableData}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||||
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||||
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
||||||
{ title: "模型", dataIndex: "models", render: (value: string[]) => <Typography.Text ellipsis style={{ maxWidth: 360 }}>{modelSummary(value || [])}</Typography.Text> },
|
{
|
||||||
|
title: "模型",
|
||||||
|
dataIndex: "models",
|
||||||
|
render: (value: string[]) => (
|
||||||
|
<Typography.Text ellipsis style={{ maxWidth: 360 }}>
|
||||||
|
{modelSummary(value || [])}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: "权重", dataIndex: "weight", width: 88 },
|
{ title: "权重", dataIndex: "weight", width: 88 },
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 220,
|
width: 220,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (_, __, index) => (
|
render: (_, item) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button size="small" onClick={() => openTestDialog(index)}>测试</Button>
|
<Button size="small" onClick={() => openTestDialog(item._index)}>
|
||||||
<Button size="small" onClick={() => openChannelDrawer(index)}>编辑</Button>
|
测试
|
||||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
</Button>
|
||||||
|
<Button size="small" onClick={() => openChannelDrawer(item._index)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
const nextChannels = [...channels];
|
const nextChannels = [...channels];
|
||||||
nextChannels.splice(index, 1);
|
nextChannels.splice(item._index, 1);
|
||||||
void persistChannels(nextChannels);
|
void persistChannels(nextChannels);
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -369,33 +609,159 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<Drawer title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"} open={isChannelDrawerOpen} size={560} onClose={closeChannelDrawer} extra={<Space><Button onClick={closeChannelDrawer}>取消</Button><Button type="primary" onClick={() => void saveChannel()}>保存</Button></Space>} destroyOnHidden>
|
<Drawer
|
||||||
|
title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"}
|
||||||
|
open={isChannelDrawerOpen}
|
||||||
|
size={560}
|
||||||
|
onClose={closeChannelDrawer}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeChannelDrawer}>取消</Button>
|
||||||
|
<Button type="primary" onClick={() => void saveChannel()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
|
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}><Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}><Input /></Form.Item></Col>
|
<Col span={12}>
|
||||||
<Col span={12}><Form.Item name="protocol" label="协议"><Select options={[{ label: "OpenAI", value: "openai" }]} /></Form.Item></Col>
|
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}>
|
||||||
<Col span={12}><Form.Item name="weight" label="权重"><InputNumber min={1} step={1} className="!w-full" /></Form.Item></Col>
|
<Input />
|
||||||
<Col span={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
|
</Form.Item>
|
||||||
<Col span={24}><Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}><Input /></Form.Item></Col>
|
</Col>
|
||||||
<Col span={24}><Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}><Input.Password /></Form.Item></Col>
|
<Col span={12}>
|
||||||
|
<Form.Item name="protocol" label="协议">
|
||||||
|
<Select options={[{ label: "OpenAI", value: "openai" }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="weight" label="权重">
|
||||||
|
<InputNumber min={1} step={1} className="!w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name="apiKey" label="API Key" rules={editingChannelIndex === null ? [{ required: true, message: "请输入 API Key" }] : []}>
|
||||||
|
<Input.Password placeholder={editingChannelIndex === null ? "" : "留空则沿用已保存的 API Key"} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item label="渠道可用模型">
|
<Form.Item label="渠道可用模型">
|
||||||
<Space.Compact style={{ width: "100%" }}>
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
<Form.Item name="models" noStyle><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item>
|
<Form.Item name="models" noStyle>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>获取模型列表</Button>
|
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button onClick={() => openChannelModelSelector()}>选择模型</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}><Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item></Col>
|
<Col span={24}>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space>{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text></Space>}
|
title={
|
||||||
|
<Space size={12}>
|
||||||
|
选择渠道模型
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已选择 {modelSelectSelected.length} / {uniqueModels([...modelSelectSource, ...modelSelectExisting]).length}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={isModelSelectorOpen}
|
||||||
|
width={960}
|
||||||
|
onCancel={closeChannelModelSelector}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeChannelModelSelector}>取消</Button>
|
||||||
|
<Button type="primary" onClick={confirmChannelModelSelector}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Flex vertical gap={14}>
|
||||||
|
<Flex gap={12} wrap>
|
||||||
|
<Input.Search placeholder="搜索模型" allowClear value={modelSelectKeyword} onChange={(event) => setModelSelectKeyword(event.target.value)} style={{ flex: "1 1 260px" }} />
|
||||||
|
<Space.Compact style={{ flex: "1 1 320px" }}>
|
||||||
|
<Input value={modelSelectNewModel} placeholder="输入模型名称" onChange={(event) => setModelSelectNewModel(event.target.value)} onPressEnter={addModelInSelector} />
|
||||||
|
<Button onClick={addModelInSelector}>增加模型</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} loading={isFetchingChannelModels} onClick={() => void fetchChannelModelList()}>
|
||||||
|
拉取模型列表
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Flex>
|
||||||
|
<Tabs
|
||||||
|
activeKey={modelSelectTab}
|
||||||
|
onChange={(key) => setModelSelectTab(key as ModelSelectTabKey)}
|
||||||
|
items={[
|
||||||
|
{ key: "new", label: `新获取的模型 (${modelSelectGroups.new.length})` },
|
||||||
|
{ key: "current", label: `已有的模型 (${modelSelectGroups.current.length})` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
当前列表已选择 {activeSelectedCount} / {activeModelSelectModels.length}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" disabled={!activeModelSelectModels.length || activeSelectedCount === activeModelSelectModels.length} onClick={selectActiveModels}>
|
||||||
|
全选当前列表
|
||||||
|
</Button>
|
||||||
|
<Button size="small" disabled={!activeSelectedCount} onClick={clearActiveModels}>
|
||||||
|
取消当前列表
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
<div style={{ maxHeight: 420, overflowY: "auto", borderTop: "1px solid var(--ant-color-border-secondary)", paddingTop: 12 }}>
|
||||||
|
{activeModelSelectModels.length ? (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", columnGap: 24, rowGap: 12 }}>
|
||||||
|
{activeModelSelectModels.map((model) => (
|
||||||
|
<Checkbox key={model} checked={modelSelectSelected.includes(model)} onChange={(event) => toggleSelectedModel(model, event.target.checked)}>
|
||||||
|
<Typography.Text style={{ wordBreak: "break-all" }}>{model}</Typography.Text>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: "48px 0", textAlign: "center" }}>
|
||||||
|
<Typography.Text type="secondary">没有匹配的模型</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
open={testChannelIndex !== null}
|
open={testChannelIndex !== null}
|
||||||
width={920}
|
width={920}
|
||||||
onCancel={closeTestDialog}
|
onCancel={closeTestDialog}
|
||||||
footer={<Space><Button onClick={closeTestDialog}>取消</Button><Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>批量测试 {selectedTestModels.length} 个模型</Button></Space>}
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeTestDialog}>取消</Button>
|
||||||
|
<Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>
|
||||||
|
批量测试 {selectedTestModels.length} 个模型
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
@@ -435,7 +801,11 @@ export default function AdminSettingsPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (_, item) => <Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>测试</Button>,
|
render: (_, item) => (
|
||||||
|
<Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -463,13 +833,34 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
|
|||||||
...emptySettings.public.modelChannel,
|
...emptySettings.public.modelChannel,
|
||||||
...(setting.modelChannel || {}),
|
...(setting.modelChannel || {}),
|
||||||
availableModels: setting.modelChannel?.availableModels || [],
|
availableModels: setting.modelChannel?.availableModels || [],
|
||||||
|
modelCosts: normalizeModelCosts(setting.modelChannel?.modelCosts || []),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
allowRegister: setting.auth?.allowRegister !== false,
|
||||||
|
linuxDo: {
|
||||||
|
enabled: setting.auth?.linuxDo?.enabled === true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeModelCosts(items: Partial<AdminSettings["public"]["modelChannel"]["modelCosts"][number]>[]) {
|
||||||
|
return items.filter((item) => item.model).map((item) => ({ model: item.model || "", credits: Math.max(0, Number(item.credits) || 0) }));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
||||||
return {
|
return {
|
||||||
channels: (setting.channels || []).map(normalizeChannel),
|
channels: (setting.channels || []).map(normalizeChannel),
|
||||||
|
promptSync: {
|
||||||
|
enabled: setting.promptSync?.enabled !== false,
|
||||||
|
cron: setting.promptSync?.cron || "*/5 * * * *",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
linuxDo: {
|
||||||
|
clientId: setting.auth?.linuxDo?.clientId || "",
|
||||||
|
clientSecret: setting.auth?.linuxDo?.clientSecret || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +877,18 @@ function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChan
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelCostCredits(items: AdminSettings["public"]["modelChannel"]["modelCosts"], model: string) {
|
||||||
|
return items.find((item) => item.model === model)?.credits || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModelCost(form: any, setModelCosts: (items: AdminModelCost[]) => void, model: string, credits: number) {
|
||||||
|
const current = (form.getFieldValue(["public", "modelChannel", "modelCosts"]) || []) as AdminSettings["public"]["modelChannel"]["modelCosts"];
|
||||||
|
const next = current.filter((item) => item.model !== model);
|
||||||
|
next.push({ model, credits: Math.max(0, credits) });
|
||||||
|
form.setFieldValue(["public", "modelChannel", "modelCosts"], next);
|
||||||
|
setModelCosts(next);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings {
|
function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings {
|
||||||
const channels = saved.private.channels.map((item, index) => ({
|
const channels = saved.private.channels.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -493,7 +896,7 @@ function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminS
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
public: saved.public,
|
public: saved.public,
|
||||||
private: { channels },
|
private: { ...saved.private, channels },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,10 +904,33 @@ function collectChannelModels(channels: AdminModelChannel[]) {
|
|||||||
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
|
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectKnownModels(settings: AdminSettings) {
|
||||||
|
return uniqueModels([
|
||||||
|
...(settings.public.modelChannel.availableModels || []),
|
||||||
|
...(settings.public.modelChannel.modelCosts || []).map((item) => item.model),
|
||||||
|
...settings.private.channels.flatMap((channel) => channel.models || []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelSelectGroups(sourceModels: string[], existingModels: string[]): Record<ModelSelectTabKey, string[]> {
|
||||||
|
const source = uniqueModels(sourceModels);
|
||||||
|
const existing = uniqueModels(existingModels);
|
||||||
|
const existingSet = new Set(existing);
|
||||||
|
return {
|
||||||
|
new: source.filter((model) => !existingSet.has(model)),
|
||||||
|
current: existing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueModels(models: string[]) {
|
function uniqueModels(models: string[]) {
|
||||||
return Array.from(new Set(models.filter(Boolean)));
|
return Array.from(new Set(models.filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterModels(models: string[], options: string[]) {
|
||||||
|
const optionSet = new Set(options);
|
||||||
|
return uniqueModels(models).filter((model) => optionSet.has(model));
|
||||||
|
}
|
||||||
|
|
||||||
function modelSummary(models: string[]) {
|
function modelSummary(models: string[]) {
|
||||||
if (!models.length) return "未配置模型";
|
if (!models.length) return "未配置模型";
|
||||||
const preview = models.slice(0, 3).join(", ");
|
const preview = models.slice(0, 3).join(", ");
|
||||||
@@ -540,6 +966,7 @@ async function collectSettings(form: any, editorMode: Record<SettingsTabKey, Edi
|
|||||||
}
|
}
|
||||||
values.private = privateSetting;
|
values.private = privateSetting;
|
||||||
}
|
}
|
||||||
|
values.public.modelChannel.availableModels = filterModels(values.public.modelChannel.availableModels, collectChannelModels(values.private.channels));
|
||||||
return normalizeSettings(values);
|
return normalizeSettings(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function AssetLibraryPage() {
|
|||||||
const total = query.data?.total || 0;
|
const total = query.data?.total || 0;
|
||||||
|
|
||||||
const toggleTag = (tag: string) => {
|
const toggleTag = (tag: string) => {
|
||||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
||||||
@@ -95,7 +95,17 @@ export default function AssetLibraryPage() {
|
|||||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">挑选团队素材,加入我的素材后继续编辑和使用。</p>
|
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">挑选团队素材,加入我的素材后继续编辑和使用。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
|
<Input
|
||||||
|
size="large"
|
||||||
|
className="w-full"
|
||||||
|
prefix={<Search className="size-4 text-stone-400" />}
|
||||||
|
value={keyword}
|
||||||
|
placeholder="按标题查询"
|
||||||
|
onChange={(event) => {
|
||||||
|
setPage(1);
|
||||||
|
setKeyword(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-6 max-w-6xl space-y-3">
|
<div className="mx-auto mt-6 max-w-6xl space-y-3">
|
||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||||
@@ -106,7 +116,15 @@ export default function AssetLibraryPage() {
|
|||||||
{ label: "文本", value: "text" },
|
{ label: "文本", value: "text" },
|
||||||
{ label: "图片", value: "image" },
|
{ label: "图片", value: "image" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
|
<Tag.CheckableTag
|
||||||
|
key={item.value || "all"}
|
||||||
|
checked={selectedType === item.value}
|
||||||
|
className={cn("prompt-filter-tag", selectedType === item.value && "is-active")}
|
||||||
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedType(item.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
@@ -115,11 +133,26 @@ export default function AssetLibraryPage() {
|
|||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
|
<Tag.CheckableTag
|
||||||
|
checked={selectedTags.length === 0}
|
||||||
|
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
|
||||||
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedTags([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
全部
|
全部
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
{availableTags.map((tag) => (
|
{availableTags.map((tag) => (
|
||||||
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
|
<Tag.CheckableTag
|
||||||
|
key={tag}
|
||||||
|
checked={selectedTags.includes(tag)}
|
||||||
|
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
|
||||||
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
toggleTag(tag);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
@@ -146,23 +179,43 @@ export default function AssetLibraryPage() {
|
|||||||
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
|
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
|
||||||
{selectedAsset ? (
|
{selectedAsset ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
|
{selectedAsset.coverUrl ? (
|
||||||
|
<Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
|
<Typography.Title level={4} className="!mb-2">
|
||||||
|
{selectedAsset.title}
|
||||||
|
</Typography.Title>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
|
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||||
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
{selectedAsset.tags.map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
<Typography.Text type="secondary" className="block text-xs">
|
||||||
|
内容
|
||||||
|
</Typography.Text>
|
||||||
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
|
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
|
||||||
</div>
|
</div>
|
||||||
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
|
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>复制文本</Button> : null}
|
{selectedAsset.type === "text" ? (
|
||||||
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>复制链接</Button> : null}
|
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
|
||||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>加入我的素材</Button>
|
复制文本
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{selectedAsset.type === "image" ? (
|
||||||
|
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
|
||||||
|
复制链接
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
|
||||||
|
加入我的素材
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -174,7 +227,20 @@ export default function AssetLibraryPage() {
|
|||||||
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
||||||
const cover = asset.coverUrl;
|
const cover = asset.coverUrl;
|
||||||
return (
|
return (
|
||||||
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
|
<Card
|
||||||
|
hoverable
|
||||||
|
className="overflow-hidden"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
cover={
|
||||||
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -185,14 +251,22 @@ function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen
|
|||||||
{asset.type === "text" ? asset.content : asset.url}
|
{asset.type === "text" ? asset.content : asset.url}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
{asset.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Tag key={tag} className="m-0 text-[11px]">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
{!asset.tags.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
{!asset.tags.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 px-4 pb-4">
|
<div className="flex items-center gap-2 px-4 pb-4">
|
||||||
<Button size="small" onClick={onOpen}>查看</Button>
|
<Button size="small" onClick={onOpen}>
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>加入我的素材</Button>
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
|
||||||
|
加入我的素材
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const kindOptions = [
|
|||||||
{ label: "全部", value: "all" },
|
{ label: "全部", value: "all" },
|
||||||
{ label: "文本", value: "text" },
|
{ label: "文本", value: "text" },
|
||||||
{ label: "图片", value: "image" },
|
{ label: "图片", value: "image" },
|
||||||
|
{ label: "视频", value: "video" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AssetsPage() {
|
export default function AssetsPage() {
|
||||||
@@ -52,7 +53,7 @@ export default function AssetsPage() {
|
|||||||
const title = Form.useWatch("title", form) || "";
|
const title = Form.useWatch("title", form) || "";
|
||||||
const tags = Form.useWatch("tags", form) || [];
|
const tags = Form.useWatch("tags", form) || [];
|
||||||
const content = Form.useWatch("content", form) || "";
|
const content = Form.useWatch("content", form) || "";
|
||||||
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]);
|
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image" || asset.kind === "video"), [assets]);
|
||||||
|
|
||||||
const filteredAssets = useMemo(() => {
|
const filteredAssets = useMemo(() => {
|
||||||
const query = keyword.trim().toLowerCase();
|
const query = keyword.trim().toLowerCase();
|
||||||
@@ -145,9 +146,9 @@ export default function AssetsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadImage = (asset: Asset) => {
|
const downloadImage = (asset: Asset) => {
|
||||||
if (asset.kind !== "image") return;
|
if (asset.kind !== "image" && asset.kind !== "video") return;
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = asset.data.dataUrl;
|
link.href = asset.kind === "video" ? asset.data.url : asset.data.dataUrl;
|
||||||
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
|
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
|
||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
@@ -169,7 +170,22 @@ export default function AssetsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||||
<Input.Search className="w-full" size="large" allowClear prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} />
|
<Input.Search
|
||||||
|
className="w-full"
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
prefix={<Search className="size-4 text-stone-400" />}
|
||||||
|
value={keyword}
|
||||||
|
placeholder="搜索标题、内容、标签或来源"
|
||||||
|
onChange={(event) => {
|
||||||
|
setPage(1);
|
||||||
|
setKeyword(event.target.value);
|
||||||
|
}}
|
||||||
|
onSearch={(value) => {
|
||||||
|
setPage(1);
|
||||||
|
setKeyword(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||||
@@ -178,13 +194,25 @@ export default function AssetsPage() {
|
|||||||
<div className="text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
<div className="text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{kindOptions.map((option) => (
|
{kindOptions.map((option) => (
|
||||||
<Tag.CheckableTag key={option.value} checked={kindFilter === option.value} className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")} onChange={() => { setPage(1); setKindFilter(option.value as AssetKind | "all"); }}>
|
<Tag.CheckableTag
|
||||||
|
key={option.value}
|
||||||
|
checked={kindFilter === option.value}
|
||||||
|
className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")}
|
||||||
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
setKindFilter(option.value as AssetKind | "all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300" onClick={openCreate}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
新增素材
|
新增素材
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,15 +222,7 @@ export default function AssetsPage() {
|
|||||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{visibleAssets.map((asset) => (
|
{visibleAssets.map((asset) => (
|
||||||
<AssetCard
|
<AssetCard key={asset.id} asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
|
||||||
key={asset.id}
|
|
||||||
asset={asset}
|
|
||||||
onOpen={() => setPreviewAsset(asset)}
|
|
||||||
onEdit={() => openEdit(asset)}
|
|
||||||
onCopy={copyAssetText}
|
|
||||||
onDownload={downloadImage}
|
|
||||||
onDelete={() => setDeletingAsset(asset)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -228,7 +248,13 @@ export default function AssetsPage() {
|
|||||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||||
<Form.Item name="kind" label="类型">
|
<Form.Item name="kind" label="类型">
|
||||||
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} />
|
<Select
|
||||||
|
options={[
|
||||||
|
{ label: "文本", value: "text" },
|
||||||
|
{ label: "图片", value: "image" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setFormKind(value)}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||||
@@ -236,7 +262,9 @@ export default function AssetsPage() {
|
|||||||
<Form.Item name="coverUrl" label="封面 URL">
|
<Form.Item name="coverUrl" label="封面 URL">
|
||||||
<Space.Compact className="w-full">
|
<Space.Compact className="w-full">
|
||||||
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
|
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
|
||||||
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>上传</Button>
|
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
|
||||||
|
上传
|
||||||
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="tags" label="标签">
|
<Form.Item name="tags" label="标签">
|
||||||
@@ -257,8 +285,18 @@ export default function AssetsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Form.Item label="图片内容" required>
|
<Form.Item label="图片内容" required>
|
||||||
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
||||||
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>选择图片文件</Button>
|
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||||||
{imageDraft ? <Typography.Text type="secondary" className="ml-3 text-xs">{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}</Typography.Text> : <Typography.Text type="secondary" className="ml-3 text-xs">未选择图片</Typography.Text>}
|
选择图片文件
|
||||||
|
</Button>
|
||||||
|
{imageDraft ? (
|
||||||
|
<Typography.Text type="secondary" className="ml-3 text-xs">
|
||||||
|
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary" className="ml-3 text-xs">
|
||||||
|
未选择图片
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
@@ -266,24 +304,50 @@ export default function AssetsPage() {
|
|||||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
||||||
<Typography.Text strong>预览</Typography.Text>
|
<Typography.Text strong>预览</Typography.Text>
|
||||||
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||||
{coverUrl || imageDraft?.dataUrl ? <img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>}
|
{coverUrl || imageDraft?.dataUrl ? (
|
||||||
|
<img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||||||
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text>
|
<Typography.Text strong ellipsis className="block">
|
||||||
|
{title || "未命名素材"}
|
||||||
|
</Typography.Text>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{tags.length ? tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>) : <Tag className="m-0">未打标签</Tag>}
|
{tags.length ? (
|
||||||
|
tags.map((tag) => (
|
||||||
|
<Tag key={tag} className="m-0">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Tag className="m-0">未打标签</Tag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
<input
|
||||||
|
ref={coverInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
void readCoverFile(event.target.files?.[0]);
|
void readCoverFile(event.target.files?.[0]);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}} />
|
}}
|
||||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
/>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
void readImageFile(event.target.files?.[0]);
|
void readImageFile(event.target.files?.[0]);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||||||
@@ -305,7 +369,11 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
|||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
cover={
|
cover={
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
{cover ? (
|
||||||
|
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -314,25 +382,47 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||||||
<Typography.Text type="secondary" className="mt-1 block text-xs">{asset.source || "未标注来源"}</Typography.Text>
|
<Typography.Text type="secondary" className="mt-1 block text-xs">
|
||||||
|
{asset.source || "未标注来源"}
|
||||||
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
|
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||||
{summary}
|
{summary}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{(asset.tags || []).slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
{(asset.tags || []).slice(0, 3).map((tag) => (
|
||||||
|
<Tag key={tag} className="m-0 text-[11px]">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
{!asset.tags?.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
{!asset.tags?.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 px-4 pb-4">
|
<div className="flex items-center gap-2 px-4 pb-4">
|
||||||
<Button size="small" onClick={onOpen}>查看</Button>
|
<Button size="small" onClick={onOpen}>
|
||||||
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>编辑</Button>
|
查看
|
||||||
{asset.kind === "text" ? <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>复制</Button> : null}
|
</Button>
|
||||||
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>下载</Button> : null}
|
{asset.kind !== "video" ? (
|
||||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -344,22 +434,53 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | nu
|
|||||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||||
{asset ? (
|
{asset ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{cover ? <Image src={cover} alt={asset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
{cover ? (
|
||||||
|
<Image src={cover} alt={asset.title} className="rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title>
|
<Typography.Title level={4} className="!mb-2">
|
||||||
|
{asset.title}
|
||||||
|
</Typography.Title>
|
||||||
<Space size={[4, 4]} wrap>
|
<Space size={[4, 4]} wrap>
|
||||||
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
|
<Tag>{asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : "文本"}</Tag>
|
||||||
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
{(asset.tags || []).map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
<Typography.Text type="secondary" className="block text-xs">
|
||||||
{asset.kind === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>}
|
内容
|
||||||
|
</Typography.Text>
|
||||||
|
{asset.kind === "text" ? (
|
||||||
|
<Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph>
|
||||||
|
) : 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>
|
</div>
|
||||||
{asset.note ? <div><Typography.Text type="secondary">备注</Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null}
|
{asset.note ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">备注</Typography.Text>
|
||||||
|
<Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Space>
|
<Space>
|
||||||
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>复制文本</Button> : null}
|
{asset.kind === "text" ? (
|
||||||
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>下载图片</Button> : null}
|
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
|
||||||
|
复制文本
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{asset.kind === "image" || asset.kind === "video" ? (
|
||||||
|
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
|
||||||
|
{asset.kind === "video" ? "下载视频" : "下载图片"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -373,11 +494,5 @@ function assetSummary(asset: Asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assetSearchText(asset: Asset) {
|
function assetSearchText(asset: Asset) {
|
||||||
return [
|
return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
|
||||||
asset.title,
|
|
||||||
asset.source || "",
|
|
||||||
asset.note || "",
|
|
||||||
(asset.tags || []).join(" "),
|
|
||||||
asset.kind === "text" ? asset.data.content : asset.data.mimeType,
|
|
||||||
].join(" ").toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"
|
|||||||
|
|
||||||
export type AssetPickerTab = "my-assets" | "library";
|
export type AssetPickerTab = "my-assets" | "library";
|
||||||
|
|
||||||
export type InsertAssetPayload =
|
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string } | { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number };
|
||||||
| { kind: "text"; content: string; title: string }
|
|
||||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -31,15 +29,7 @@ export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onC
|
|||||||
}, [open, defaultTab]);
|
}, [open, defaultTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||||
title="选择素材"
|
|
||||||
open={open}
|
|
||||||
onCancel={onClose}
|
|
||||||
footer={null}
|
|
||||||
width={860}
|
|
||||||
destroyOnHidden
|
|
||||||
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
|
|
||||||
>
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
||||||
@@ -58,6 +48,7 @@ const kindOptions = [
|
|||||||
{ label: "全部", value: "all" },
|
{ label: "全部", value: "all" },
|
||||||
{ label: "文本", value: "text" },
|
{ label: "文本", value: "text" },
|
||||||
{ label: "图片", value: "image" },
|
{ label: "图片", value: "image" },
|
||||||
|
{ label: "视频", value: "video" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||||
@@ -102,15 +93,25 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
|
|||||||
placeholder="搜索素材"
|
placeholder="搜索素材"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
|
{[
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "文本", value: "text" },
|
||||||
|
{ label: "图片", value: "image" },
|
||||||
|
].map((opt) => (
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
key={opt.value || "all"}
|
key={opt.value || "all"}
|
||||||
checked={kindFilter === opt.value}
|
checked={kindFilter === opt.value}
|
||||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
setKindFilter(opt.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
@@ -119,18 +120,13 @@ function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => v
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="flex justify-center py-16"><Spin /></div>
|
<div className="flex justify-center py-16">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
) : items.length ? (
|
) : items.length ? (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{items.map((asset) => (
|
{items.map((asset) => (
|
||||||
<PickerCard
|
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
|
||||||
key={asset.id}
|
|
||||||
title={asset.title}
|
|
||||||
kind={asset.type}
|
|
||||||
cover={asset.coverUrl}
|
|
||||||
loading={inserting === asset.id}
|
|
||||||
onClick={() => void handleInsert(asset)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -157,14 +153,12 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
|
|||||||
{cover ? (
|
{cover ? (
|
||||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">{title}</div>
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="p-2.5">
|
<div className="p-2.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
|
<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>
|
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -172,9 +166,7 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
|
|||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
|
||||||
插入
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,7 +191,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = keyword.trim().toLowerCase();
|
const query = keyword.trim().toLowerCase();
|
||||||
return assets
|
return assets
|
||||||
.filter((a) => a.kind === "text" || a.kind === "image")
|
.filter((a) => a.kind === "text" || a.kind === "image" || a.kind === "video")
|
||||||
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
|
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
|
||||||
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
|
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
|
||||||
}, [assets, keyword, kindFilter]);
|
}, [assets, keyword, kindFilter]);
|
||||||
@@ -215,7 +207,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
if (asset.kind === "text") {
|
if (asset.kind === "text") {
|
||||||
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
|
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
|
||||||
} else {
|
} else {
|
||||||
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
|
onInsert(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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,7 +221,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
placeholder="搜索素材"
|
placeholder="搜索素材"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{kindOptions.map((opt) => (
|
{kindOptions.map((opt) => (
|
||||||
@@ -237,7 +232,10 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
checked={kindFilter === opt.value}
|
checked={kindFilter === opt.value}
|
||||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
onChange={() => {
|
||||||
|
setPage(1);
|
||||||
|
setKindFilter(opt.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||||
import { Button, ConfigProvider, Input, InputNumber, Modal, Popover, Segmented, Tooltip } from "antd";
|
import { Button, Modal, Tooltip } from "antd";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -17,6 +18,7 @@ import { useAssetStore } from "@/stores/use-asset-store";
|
|||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import type { ReferenceImage } from "@/types/image";
|
import type { ReferenceImage } from "@/types/image";
|
||||||
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
||||||
|
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||||
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
|
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
|
||||||
|
|
||||||
@@ -40,8 +42,8 @@ type CanvasAssistantPanelProps = {
|
|||||||
|
|
||||||
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
|
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const config = useConfigStore((state) => state.config);
|
|
||||||
const effectiveConfig = useEffectiveConfig();
|
const effectiveConfig = useEffectiveConfig();
|
||||||
|
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||||
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
||||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||||
@@ -56,7 +58,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [resizing, setResizing] = useState(false);
|
const [resizing, setResizing] = useState(false);
|
||||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => sessions.length ? sessions : [createSession()]);
|
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||||
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,7 +86,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
}, [selectedNodeKey]);
|
}, [selectedNodeKey]);
|
||||||
|
|
||||||
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
|
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
|
||||||
setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session));
|
setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
|
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
|
||||||
@@ -99,7 +101,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
|
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
|
||||||
updateSession(sessionId, (session) => ({
|
updateSession(sessionId, (session) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message),
|
messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -159,7 +161,9 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (nextMode === "image") {
|
if (nextMode === "image") {
|
||||||
const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })));
|
const referenceImages: ReferenceImage[] = await Promise.all(
|
||||||
|
refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })),
|
||||||
|
);
|
||||||
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
|
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
|
||||||
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
|
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
|
||||||
updateMessage(session.id, assistantId, {
|
updateMessage(session.id, assistantId, {
|
||||||
@@ -224,10 +228,19 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||||
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
|
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<motion.aside className="relative flex shrink-0 flex-col border-l" initial={{ x: 48 }} animate={{ x: closing ? 28 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}>
|
<motion.aside
|
||||||
|
className="relative flex shrink-0 flex-col border-l"
|
||||||
|
initial={{ x: 48 }}
|
||||||
|
animate={{ x: closing ? 28 : 0 }}
|
||||||
|
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||||
|
>
|
||||||
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
|
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
|
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium"><Sparkles className="size-4" />{view === "history" ? "历史记录" : "画布助手(未开发)"}</div>
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
{view === "history" ? "历史记录" : "画布助手(未开发)"}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{view === "history" ? (
|
{view === "history" ? (
|
||||||
<>
|
<>
|
||||||
@@ -235,7 +248,15 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
|
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="删除全部">
|
<Tooltip title="删除全部">
|
||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
className="!h-8 !w-8 !min-w-8"
|
||||||
|
style={iconButtonStyle}
|
||||||
|
icon={<X className="size-4" />}
|
||||||
|
disabled={!historySessions.length}
|
||||||
|
onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -243,7 +264,18 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
|
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="新对话">
|
<Tooltip title="新对话">
|
||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Plus className="size-4" />} disabled={!hasMessages} onClick={() => { startChatSession(); setView("chat"); }} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
className="!h-8 !w-8 !min-w-8"
|
||||||
|
style={iconButtonStyle}
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
disabled={!hasMessages}
|
||||||
|
onClick={() => {
|
||||||
|
startChatSession();
|
||||||
|
setView("chat");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="配置">
|
<Tooltip title="配置">
|
||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
|
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
|
||||||
@@ -260,8 +292,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
sessions={historySessions}
|
sessions={historySessions}
|
||||||
activeSession={activeSession}
|
activeSession={activeSession}
|
||||||
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
|
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
|
||||||
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))}
|
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id)))}
|
||||||
onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }}
|
onOpen={(id) => {
|
||||||
|
setLocalActiveSessionId(id);
|
||||||
|
setView("chat");
|
||||||
|
}}
|
||||||
onDelete={(id) => setDeleteChatIds([id])}
|
onDelete={(id) => setDeleteChatIds([id])}
|
||||||
/>
|
/>
|
||||||
) : messages.length ? (
|
) : messages.length ? (
|
||||||
@@ -270,14 +305,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
|
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
|
||||||
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
|
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
|
||||||
<span>Infinite Canvas</span>
|
<span>Infinite Canvas</span>
|
||||||
<DiaTextReveal
|
<DiaTextReveal className="absolute inset-0" colors={["#A97CF8", "#F38CB8", "#FDCC92"]} textColor="transparent" duration={1.8} startOnView={false} text="Infinite Canvas" />
|
||||||
className="absolute inset-0"
|
|
||||||
colors={["#A97CF8", "#F38CB8", "#FDCC92"]}
|
|
||||||
textColor="transparent"
|
|
||||||
duration={1.8}
|
|
||||||
startOnView={false}
|
|
||||||
text="Infinite Canvas"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
|
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +329,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
|
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
|
||||||
}}
|
}}
|
||||||
onPasteImage={onPasteImage}
|
onPasteImage={onPasteImage}
|
||||||
|
modelCosts={modelCosts}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -309,7 +338,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
open={deleteChatIds.length > 0}
|
open={deleteChatIds.length > 0}
|
||||||
centered
|
centered
|
||||||
onCancel={() => setDeleteChatIds([])}
|
onCancel={() => setDeleteChatIds([])}
|
||||||
footer={<>
|
footer={
|
||||||
|
<>
|
||||||
<Button onClick={() => setDeleteChatIds([])}>取消</Button>
|
<Button onClick={() => setDeleteChatIds([])}>取消</Button>
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
@@ -321,7 +351,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</>}
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -343,6 +374,7 @@ function AssistantComposer({
|
|||||||
onMissingConfig,
|
onMissingConfig,
|
||||||
onRemoveReference,
|
onRemoveReference,
|
||||||
onPasteImage,
|
onPasteImage,
|
||||||
|
modelCosts,
|
||||||
}: {
|
}: {
|
||||||
mode: AssistantMode;
|
mode: AssistantMode;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -356,14 +388,19 @@ function AssistantComposer({
|
|||||||
onMissingConfig: () => void;
|
onMissingConfig: () => void;
|
||||||
onRemoveReference: (id: string) => void;
|
onRemoveReference: (id: string) => void;
|
||||||
onPasteImage: (file: File) => void;
|
onPasteImage: (file: File) => void;
|
||||||
|
modelCosts?: { model: string; credits: number }[];
|
||||||
}) {
|
}) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const activeModel = mode === "image" ? config.imageModel || config.model : config.textModel || config.model;
|
||||||
|
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: activeModel, count: mode === "image" ? config.count : 1 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
|
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||||
{references.length ? (
|
{references.length ? (
|
||||||
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
|
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
|
||||||
{references.map((item) => <AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />)}
|
{references.map((item) => (
|
||||||
|
<AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||||
@@ -386,97 +423,69 @@ function AssistantComposer({
|
|||||||
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
|
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
<div className="canvas-composer-tools flex min-w-0 flex-1 items-center gap-1">
|
||||||
<CanvasPromptLibrary onSelect={onPromptChange} />
|
<CanvasPromptLibrary onSelect={onPromptChange} />
|
||||||
<CanvasThemeProvider theme={theme}>
|
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||||
<Segmented
|
|
||||||
size="small"
|
|
||||||
className="canvas-composer-mode !rounded-full !p-0.5"
|
|
||||||
value={mode}
|
|
||||||
onChange={(value) => onModeChange(value as AssistantMode)}
|
|
||||||
options={[
|
|
||||||
{ value: "ask", label: <Tooltip title="文本"><MessageSquare className="size-4" /></Tooltip> },
|
|
||||||
{ value: "image", label: <Tooltip title="生图"><ImageIcon className="size-4" /></Tooltip> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CanvasThemeProvider>
|
|
||||||
{mode === "image" ? (
|
{mode === "image" ? (
|
||||||
<ImageSettingsPopover config={config} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
<>
|
||||||
|
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||||
|
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10 shrink-0" icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} disabled={isRunning || !prompt.trim()} onClick={() => void onSubmit()} aria-label="发送" />
|
<Button
|
||||||
</div>
|
type="primary"
|
||||||
</div>
|
className="!h-10 !min-w-16 shrink-0 !rounded-full !px-3"
|
||||||
</div>
|
disabled={isRunning || !prompt.trim()}
|
||||||
);
|
onClick={() => void onSubmit()}
|
||||||
}
|
aria-label="发送"
|
||||||
|
|
||||||
function ImageSettingsPopover({ config, onConfigChange, onMissingConfig }: { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void }) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
|
||||||
const quality = config.quality || "auto";
|
|
||||||
const count = String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))));
|
|
||||||
const sizes = ["1:1", "3:2", "2:3", "16:9", "9:16", "auto"];
|
|
||||||
const customSize = sizes.includes(config.size || "auto") ? "" : config.size;
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="topLeft"
|
|
||||||
arrow={false}
|
|
||||||
overlayClassName="canvas-image-settings-popover"
|
|
||||||
color={theme.toolbar.panel}
|
|
||||||
content={(
|
|
||||||
<CanvasThemeProvider theme={theme}>
|
|
||||||
<div className="w-[330px] space-y-4" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
|
||||||
<div className="text-base font-semibold">图像设置</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<SettingTitle color={theme.node.muted}>模型</SettingTitle>
|
|
||||||
<ModelPicker className="!h-9 !w-full !max-w-none" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} fullWidth />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
|
||||||
<Segmented block value={quality} onChange={(value) => onConfigChange("quality", String(value))} options={[{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
|
||||||
{sizes.map((size) => (
|
|
||||||
<Button key={size} size="small" type={(config.size || "auto") === size ? "primary" : "default"} className="!h-7 !rounded-md !px-1.5" onClick={() => onConfigChange("size", size)}>
|
|
||||||
{size}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<Input size="small" className="col-span-2 !h-7" placeholder="自定义比例" value={customSize} onChange={(event) => onConfigChange("size", event.target.value.trim() || "auto")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_92px] gap-2">
|
|
||||||
<Segmented block value={["1", "2", "3", "4"].includes(count) ? count : undefined} onChange={(value) => onConfigChange("count", String(value))} options={["1", "2", "3", "4"].map((value) => ({ value, label: `${value} 张` }))} />
|
|
||||||
<InputNumber min={1} max={15} size="small" className="canvas-control-number !h-8 !w-full" value={Number(count)} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CanvasThemeProvider>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Button size="small" type="text" className="!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5" style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="truncate">{qualityLabel(quality)} · {config.size || "auto"} · {count} 张</span>
|
<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>
|
</Button>
|
||||||
</Popover>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={{ token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel }, components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text }, Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 } } }}>
|
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
|
||||||
{children}
|
{[
|
||||||
</ConfigProvider>
|
{ value: "ask" as const, title: "对话", icon: <MessageSquare className="size-4" /> },
|
||||||
|
{ value: "image" as const, title: "生图", icon: <ImageIcon className="size-4" /> },
|
||||||
|
].map((item) => (
|
||||||
|
<Tooltip key={item.value} title={item.title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="canvas-composer-mode-button flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent transition"
|
||||||
|
style={{ background: mode === item.value ? theme.node.activeStroke : "transparent", color: mode === item.value ? theme.node.panel : theme.node.text }}
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
aria-label={item.title}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||||
return <div className="text-xs font-medium" style={{ color }}>{children}</div>;
|
return (
|
||||||
|
<div className="text-xs font-medium" style={{ color }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function qualityLabel(value: string) {
|
function qualityLabel(value: string) {
|
||||||
@@ -500,8 +509,16 @@ function AssistantMessages({
|
|||||||
<>
|
<>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
|
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
|
||||||
<div className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6" style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}>
|
<div
|
||||||
{message.role === "assistant" ? <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"><MessageSquare className="size-3.5" />回答</div> : null}
|
className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6"
|
||||||
|
style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}
|
||||||
|
>
|
||||||
|
{message.role === "assistant" ? (
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
回答
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||||
@@ -515,7 +532,14 @@ function AssistantMessages({
|
|||||||
{message.images?.map((image) => (
|
{message.images?.map((image) => (
|
||||||
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
|
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
|
||||||
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
|
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
|
||||||
<Button type="text" className="!h-8 !w-full !rounded-none" style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }} icon={<Plus className="size-3.5" />} onClick={() => onInsertImage(image)} title="插入画布" />
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="!h-8 !w-full !rounded-none"
|
||||||
|
style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||||
|
icon={<Plus className="size-3.5" />}
|
||||||
|
onClick={() => onInsertImage(image)}
|
||||||
|
title="插入画布"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +584,9 @@ function AssistantHistory({
|
|||||||
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
|
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||||
{message.references?.map((item) => <AssistantReferenceChip key={item.id} item={item} />)}
|
{message.references?.map((item) => (
|
||||||
|
<AssistantReferenceChip key={item.id} item={item} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -570,9 +596,21 @@ function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantRefer
|
|||||||
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
||||||
return (
|
return (
|
||||||
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
|
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
|
||||||
{item.dataUrl ? <img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" /> : <span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>{text}</span>}
|
{item.dataUrl ? (
|
||||||
|
<img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{onRemove ? (
|
{onRemove ? (
|
||||||
<button type="button" className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }} onClick={onRemove} aria-label="移除引用">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
|
||||||
|
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="移除引用"
|
||||||
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -600,19 +638,21 @@ function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
|
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
|
||||||
return Promise.all(messages.map(async (message, index) => {
|
return Promise.all(
|
||||||
|
messages.map(async (message, index) => {
|
||||||
if (message.role === "assistant") return { role: "assistant", content: message.text };
|
if (message.role === "assistant") return { role: "assistant", content: message.text };
|
||||||
if (index !== messages.length - 1) return { role: "user", content: message.text };
|
if (index !== messages.length - 1) return { role: "user", content: message.text };
|
||||||
const refs = message.references || [];
|
const refs = message.references || [];
|
||||||
return {
|
return {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
...refs.flatMap((item) => item.text ? [{ type: "text" as const, text: item.text }] : []),
|
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: item.text }] : [])),
|
||||||
{ type: "text", text: message.text },
|
{ type: "text", text: message.text },
|
||||||
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
|
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(): CanvasAssistantSession {
|
function createSession(): CanvasAssistantSession {
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play } from "lucide-react";
|
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play, Video } from "lucide-react";
|
||||||
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
|
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
|
||||||
|
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
|
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasSizePicker } from "./canvas-size-picker";
|
import { CanvasSizePicker } from "./canvas-size-picker";
|
||||||
|
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
||||||
const [editingText, setEditingText] = useState("");
|
const [editingText, setEditingText] = useState("");
|
||||||
const globalConfig = useConfigStore((state) => state.config);
|
const globalConfig = useEffectiveConfig();
|
||||||
|
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const mode = node.metadata?.generationMode || "image";
|
const mode = node.metadata?.generationMode || "image";
|
||||||
const config = buildNodeConfig(globalConfig, node, mode);
|
const config = buildNodeConfig(globalConfig, node, mode);
|
||||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
|
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
|
||||||
|
const 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 chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||||
const textInputs = inputs.filter((input) => input.type === "text");
|
const textInputs = inputs.filter((input) => input.type === "text");
|
||||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
const imageInputs = inputs.filter((input) => input.type === "image");
|
||||||
@@ -73,8 +77,33 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
value={mode}
|
value={mode}
|
||||||
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
|
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "image", label: <span className="inline-flex items-center gap-1"><ImageIcon className="size-3.5" />生图</span> },
|
{
|
||||||
{ value: "text", label: <span className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" />文本</span> },
|
value: "image",
|
||||||
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ImageIcon className="size-3.5" />
|
||||||
|
生图
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "text",
|
||||||
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
文本
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "video",
|
||||||
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Video className="size-3.5" />
|
||||||
|
视频
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,12 +112,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||||
<button
|
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
|
||||||
type="button"
|
|
||||||
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]"
|
|
||||||
style={chipStyle}
|
|
||||||
onClick={() => setPreviewOpen(true)}
|
|
||||||
>
|
|
||||||
<Eye className="size-3.5" />
|
<Eye className="size-3.5" />
|
||||||
预览
|
预览
|
||||||
</button>
|
</button>
|
||||||
@@ -96,8 +120,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
|
|
||||||
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
|
<div className="mb-2 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 />
|
<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}
|
{mode === "video" ? <CanvasVideoSettingsPopover config={config} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} /> : 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 })} />
|
{mode === "video" ? 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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -106,9 +130,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
|
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={() => onGenerate(node.id)}
|
onClick={() => onGenerate(node.id)}
|
||||||
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
|
|
||||||
>
|
>
|
||||||
开始生成
|
<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>
|
</Button>
|
||||||
<Modal
|
<Modal
|
||||||
title="输入预览"
|
title="输入预览"
|
||||||
@@ -132,7 +162,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
||||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||||
{imageInputs.map((input, index) => <ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />)}
|
{imageInputs.map((input, index) => (
|
||||||
|
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PreviewSection>
|
</PreviewSection>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +172,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{textInputs.map((input, index) => <TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />)}
|
{textInputs.map((input, index) => (
|
||||||
|
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PreviewSection>
|
</PreviewSection>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,12 +183,18 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
<>
|
<>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="text-sm font-semibold">编辑文本提示词</div>
|
<div className="text-sm font-semibold">编辑文本提示词</div>
|
||||||
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>收起</Button>
|
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
|
||||||
|
收起
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
|
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
<Button size="small" onClick={() => setEditingTextId(null)}>取消</Button>
|
<Button size="small" onClick={() => setEditingTextId(null)}>
|
||||||
<Button size="small" type="primary" onClick={saveTextEdit}>保存</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={saveTextEdit}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -187,7 +227,23 @@ function PreviewSection({ title, count, empty, children }: { title: string; coun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) {
|
function TextSortCard({
|
||||||
|
input,
|
||||||
|
textIndex,
|
||||||
|
textTotal,
|
||||||
|
inputs,
|
||||||
|
theme,
|
||||||
|
onMove,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
input: NodeGenerationInput;
|
||||||
|
textIndex: number;
|
||||||
|
textTotal: number;
|
||||||
|
inputs: NodeGenerationInput[];
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||||
|
onEdit: (input: NodeGenerationInput) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -202,7 +258,21 @@ function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEd
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) {
|
function ImageSortCard({
|
||||||
|
input,
|
||||||
|
imageIndex,
|
||||||
|
imageTotal,
|
||||||
|
inputs,
|
||||||
|
theme,
|
||||||
|
onMove,
|
||||||
|
}: {
|
||||||
|
input: NodeGenerationInput;
|
||||||
|
imageIndex: number;
|
||||||
|
imageTotal: number;
|
||||||
|
inputs: NodeGenerationInput[];
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||||
|
}) {
|
||||||
if (!input.image) return null;
|
if (!input.image) return null;
|
||||||
return (
|
return (
|
||||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||||
@@ -243,12 +313,14 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
|
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
|
||||||
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
|
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
|
||||||
return {
|
return {
|
||||||
...globalConfig,
|
...globalConfig,
|
||||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||||
quality: globalConfig.quality || defaultConfig.quality,
|
quality: globalConfig.quality || defaultConfig.quality,
|
||||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||||
|
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||||
|
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
|||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
||||||
|
|
||||||
export function ConnectionPath({
|
export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
|
||||||
connection,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
active,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
connection: CanvasConnection;
|
|
||||||
from: CanvasNodeData;
|
|
||||||
to: CanvasNodeData;
|
|
||||||
active: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const startX = from.position.x + from.width;
|
const startX = from.position.x + from.width;
|
||||||
const startY = from.position.y + from.height / 2;
|
const startY = from.position.y + from.height / 2;
|
||||||
@@ -50,15 +38,7 @@ export function ConnectionPath({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveConnectionPath({
|
export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
|
||||||
node,
|
|
||||||
handle,
|
|
||||||
mouseWorld,
|
|
||||||
}: {
|
|
||||||
node?: CanvasNodeData;
|
|
||||||
handle: ConnectionHandle;
|
|
||||||
mouseWorld: Position;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
|||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import type { ContextMenuState } from "../types";
|
import type { ContextMenuState } from "../types";
|
||||||
|
|
||||||
export function CanvasNodeContextMenu({
|
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
|
||||||
menu,
|
|
||||||
onClose,
|
|
||||||
onDuplicate,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
menu: ContextMenuState;
|
|
||||||
onClose: () => void;
|
|
||||||
onDuplicate: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,26 +33,11 @@ export function CanvasNodeContextMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuButton({
|
function MenuButton({ icon, label, onClick, danger = false }: { icon: ReactNode; label: string; onClick?: () => void; danger?: boolean }) {
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
danger = false,
|
|
||||||
}: {
|
|
||||||
icon: ReactNode;
|
|
||||||
label: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80" style={{ color: danger ? "#f87171" : theme.node.text }} onClick={onClick}>
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80"
|
|
||||||
style={{ color: danger ? "#f87171" : theme.node.text }}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ export function CanvasDeleteProjectsDialog() {
|
|||||||
open={ids.length > 0}
|
open={ids.length > 0}
|
||||||
centered
|
centered
|
||||||
onCancel={() => setDeleteIds([])}
|
onCancel={() => setDeleteIds([])}
|
||||||
footer={<><Button onClick={() => setDeleteIds([])}>取消</Button><Button danger type="primary" onClick={confirm}>删除</Button></>}
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setDeleteIds([])}>取消</Button>
|
||||||
|
<Button danger type="primary" onClick={confirm}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-stone-500">将删除 {ids.length} 个画布,里面的节点和连线也会一起移除。</p>
|
<p className="text-sm text-stone-500">将删除 {ids.length} 个画布,里面的节点和连线也会一起移除。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
import { Button, ConfigProvider, Popover } from "antd";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
|
||||||
|
const qualityOptions = [
|
||||||
|
{ value: "auto", label: "自动" },
|
||||||
|
{ value: "high", label: "高" },
|
||||||
|
{ value: "medium", label: "中" },
|
||||||
|
{ value: "low", label: "低" },
|
||||||
|
];
|
||||||
|
const aspectOptions = [
|
||||||
|
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
|
||||||
|
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
|
||||||
|
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
|
||||||
|
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
|
||||||
|
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
|
||||||
|
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
|
||||||
|
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
|
||||||
|
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
|
||||||
|
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
|
||||||
|
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
|
||||||
|
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
|
||||||
|
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type CanvasImageSettingsPopoverProps = {
|
||||||
|
config: AiConfig;
|
||||||
|
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||||
|
onMissingConfig?: () => void;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
buttonClassName?: string;
|
||||||
|
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||||
|
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const quality = config.quality || "auto";
|
||||||
|
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||||
|
const activeSize = config.size || "auto";
|
||||||
|
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||||
|
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||||
|
const selectAspect = (value: string) => {
|
||||||
|
const option = aspectOptions.find((item) => item.value === value);
|
||||||
|
onConfigChange("size", option?.size || option?.value || "auto");
|
||||||
|
};
|
||||||
|
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||||
|
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
|
||||||
|
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement={placement}
|
||||||
|
arrow={false}
|
||||||
|
overlayClassName="canvas-image-settings-popover"
|
||||||
|
color={theme.toolbar.panel}
|
||||||
|
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
content={
|
||||||
|
<CanvasImageSettingsTheme theme={theme}>
|
||||||
|
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<div className="text-xl font-semibold">图像设置</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{qualityOptions.map((item) => (
|
||||||
|
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||||
|
{item.label}
|
||||||
|
</OptionPill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||||
|
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||||
|
<span className="text-lg opacity-45">↔</span>
|
||||||
|
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{aspectOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
className="flex h-[86px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-sm transition hover:opacity-80"
|
||||||
|
style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={() => selectAspect(item.value)}
|
||||||
|
>
|
||||||
|
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
|
||||||
|
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||||
|
{value} 张
|
||||||
|
</OptionPill>
|
||||||
|
))}
|
||||||
|
<CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CanvasImageSettingsTheme>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||||
|
<span className="truncate">
|
||||||
|
{qualityLabel(quality)} · {selectedAspect?.label || activeSize} · {count} 张
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
|
||||||
|
components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||||
|
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||||
|
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="col-span-2 flex h-10 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) {
|
||||||
|
if (type === "auto") return null;
|
||||||
|
const ratio = width / Math.max(1, height);
|
||||||
|
const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio);
|
||||||
|
const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28;
|
||||||
|
return (
|
||||||
|
<span className="grid h-8 w-10 place-items-center">
|
||||||
|
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs font-medium" style={{ color }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityLabel(value: string) {
|
||||||
|
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSizeDimensions(size: string, fallback: { width: number; height: number }) {
|
||||||
|
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||||
|
return {
|
||||||
|
width: match ? Number(match[1]) : fallback.width,
|
||||||
|
height: match ? Number(match[2]) : fallback.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,17 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
|||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||||
|
|
||||||
export function Minimap({
|
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
|
||||||
nodes,
|
|
||||||
viewport,
|
|
||||||
viewportSize,
|
|
||||||
onViewportChange,
|
|
||||||
}: {
|
|
||||||
nodes: CanvasNodeData[];
|
|
||||||
viewport: ViewportTransform;
|
|
||||||
viewportSize: { width: number; height: number };
|
|
||||||
onViewportChange: (viewport: ViewportTransform) => void;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -107,10 +97,7 @@ export function Minimap({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm" style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
|
||||||
className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm"
|
|
||||||
style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative h-full w-full cursor-crosshair"
|
className="relative h-full w-full cursor-crosshair"
|
||||||
@@ -144,10 +131,7 @@ export function Minimap({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div
|
<div className="pointer-events-none absolute border" style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }} />
|
||||||
className="pointer-events-none absolute border"
|
|
||||||
style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ const defaultParams: CanvasImageAngleParams = {
|
|||||||
wideAngle: false,
|
wideAngle: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
|
||||||
dataUrl: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (params: CanvasImageAngleParams) => void;
|
|
||||||
}) {
|
|
||||||
const [params, setParams] = useState(defaultParams);
|
const [params, setParams] = useState(defaultParams);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,7 +42,9 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
|||||||
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
|
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>重置</Button>
|
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
|
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
|
||||||
@@ -55,32 +52,37 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
|||||||
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
|
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
|
||||||
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
||||||
<span className="font-medium opacity-75">广角镜头</span>
|
<span className="font-medium opacity-75">广角镜头</span>
|
||||||
<Segmented className="w-fit" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
|
<Segmented
|
||||||
|
className="w-fit"
|
||||||
|
value={params.wideAngle ? "wide" : "standard"}
|
||||||
|
options={[
|
||||||
|
{ label: "标准", value: "standard" },
|
||||||
|
{ label: "广角", value: "wide" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => update("wideAngle", value === "wide")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI 生成</Button>
|
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
|
||||||
|
AI 生成
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: {
|
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
suffix?: string;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
||||||
<span className="font-medium opacity-75">{label}</span>
|
<span className="font-medium opacity-75">{label}</span>
|
||||||
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
|
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
|
||||||
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
|
<span className="whitespace-nowrap text-right font-semibold">
|
||||||
|
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,7 @@ const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|||||||
const minSize = 0.06;
|
const minSize = 0.06;
|
||||||
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
|
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
|
||||||
|
|
||||||
export function CanvasNodeCropDialog({
|
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
|
||||||
dataUrl,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
dataUrl: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (crop: CanvasImageCropRect) => void;
|
|
||||||
}) {
|
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
@@ -88,7 +78,11 @@ export function CanvasNodeCropDialog({
|
|||||||
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
|
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
|
||||||
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
||||||
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
|
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
|
||||||
{image ? <span>原图 {image.width} x {image.height}</span> : null}
|
{image ? (
|
||||||
|
<span>
|
||||||
|
原图 {image.width} x {image.height}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
|
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
|
||||||
{locked ? "锁定比例" : "自由比例"}
|
{locked ? "锁定比例" : "自由比例"}
|
||||||
@@ -97,8 +91,12 @@ export function CanvasNodeCropDialog({
|
|||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button onClick={() => setCrop(defaultCrop)}>重置</Button>
|
<Button onClick={() => setCrop(defaultCrop)}>重置</Button>
|
||||||
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
|
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||||
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>确认裁剪</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
|
||||||
|
确认裁剪
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -11,20 +11,18 @@ export type NodeGenerationContext = {
|
|||||||
|
|
||||||
export type NodeGenerationInput = {
|
export type NodeGenerationInput = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
type: "text" | "image";
|
type: "text" | "image" | "video";
|
||||||
title: string;
|
title: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
image?: ReferenceImage;
|
image?: ReferenceImage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildNodeGenerationContext(
|
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||||
nodeId: string,
|
|
||||||
nodes: CanvasNodeData[],
|
|
||||||
connections: CanvasConnection[],
|
|
||||||
prompt: string,
|
|
||||||
): NodeGenerationContext {
|
|
||||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||||
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
|
const upstreamText = inputs
|
||||||
|
.map((input) => input.text)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,11 +33,7 @@ export function buildNodeGenerationContext(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNodeGenerationInputs(
|
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||||
nodeId: string,
|
|
||||||
nodes: CanvasNodeData[],
|
|
||||||
connections: CanvasConnection[],
|
|
||||||
): NodeGenerationInput[] {
|
|
||||||
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||||
const image = readReferenceImage(node);
|
const image = readReferenceImage(node);
|
||||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||||
@@ -57,10 +51,7 @@ export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompl
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [{ type: "text" as const, text: context.prompt }, ...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } }))],
|
||||||
{ type: "text" as const, text: context.prompt },
|
|
||||||
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -93,8 +84,5 @@ function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connec
|
|||||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||||
const order = target?.metadata?.inputOrder || [];
|
const order = target?.metadata?.inputOrder || [];
|
||||||
return [
|
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
|
||||||
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
|
|
||||||
...upstreamNodes.filter((node) => !order.includes(node.id)),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Modal, Segmented, Tooltip } from "antd";
|
import { Modal, Segmented, Tooltip } from "antd";
|
||||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload } from "lucide-react";
|
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||||
@@ -25,6 +25,7 @@ type CanvasNodeHoverToolbarProps = {
|
|||||||
onSaveAsset: (node: CanvasNodeData) => void;
|
onSaveAsset: (node: CanvasNodeData) => void;
|
||||||
onCrop: (node: CanvasNodeData) => void;
|
onCrop: (node: CanvasNodeData) => void;
|
||||||
onAngle: (node: CanvasNodeData) => void;
|
onAngle: (node: CanvasNodeData) => void;
|
||||||
|
onViewImage: (node: CanvasNodeData) => void;
|
||||||
onRetry: (node: CanvasNodeData) => void;
|
onRetry: (node: CanvasNodeData) => void;
|
||||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||||
onDelete: (node: CanvasNodeData) => void;
|
onDelete: (node: CanvasNodeData) => void;
|
||||||
@@ -46,6 +47,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
onSaveAsset,
|
onSaveAsset,
|
||||||
onCrop,
|
onCrop,
|
||||||
onAngle,
|
onAngle,
|
||||||
|
onViewImage,
|
||||||
onRetry,
|
onRetry,
|
||||||
onToggleFreeResize,
|
onToggleFreeResize,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -55,12 +57,14 @@ export function CanvasNodeHoverToolbar({
|
|||||||
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
||||||
const top = viewport.y + node.position.y * viewport.k - 14;
|
const top = viewport.y + node.position.y * viewport.k - 14;
|
||||||
const isImage = node.type === CanvasNodeType.Image;
|
const isImage = node.type === CanvasNodeType.Image;
|
||||||
|
const isVideo = node.type === CanvasNodeType.Video;
|
||||||
const hasImage = isImage && Boolean(node.metadata?.content);
|
const hasImage = isImage && Boolean(node.metadata?.content);
|
||||||
|
const hasVideo = isVideo && Boolean(node.metadata?.content);
|
||||||
const isText = node.type === CanvasNodeType.Text;
|
const isText = node.type === CanvasNodeType.Text;
|
||||||
const isConfig = node.type === CanvasNodeType.Config;
|
const isConfig = node.type === CanvasNodeType.Config;
|
||||||
const canOpenDialog = isText || hasImage;
|
const canOpenDialog = isText || hasImage || isVideo;
|
||||||
const canRetry = node.metadata?.status === "error";
|
const canRetry = node.metadata?.status === "error";
|
||||||
const hasSpecificTools = canRetry || isText || isImage || isConfig;
|
const hasSpecificTools = canRetry || isText || isImage || isVideo || isConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -75,8 +79,8 @@ export function CanvasNodeHoverToolbar({
|
|||||||
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
||||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
{hasSpecificTools ? <ToolbarDivider /> : null}
|
||||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
||||||
{hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
||||||
{hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
{hasImage || hasVideo ? <IconAction title={hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
||||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
||||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
||||||
@@ -84,9 +88,19 @@ export function CanvasNodeHoverToolbar({
|
|||||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
||||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||||
{hasImage ? <ToolbarAction title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"} label={node.metadata?.freeResize ? "自由比例" : "锁比例"} icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />} onClick={() => onToggleFreeResize(node)} active={node.metadata?.freeResize} /> : null}
|
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video 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={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
||||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
||||||
|
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,13 +112,17 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
|||||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
||||||
const json = useMemo(() => {
|
const json = useMemo(() => {
|
||||||
if (!node) return "";
|
if (!node) return "";
|
||||||
return JSON.stringify(node, (key, value) => {
|
return JSON.stringify(
|
||||||
|
node,
|
||||||
|
(key, value) => {
|
||||||
if (key === "title") return undefined;
|
if (key === "title") return undefined;
|
||||||
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
||||||
return "[base64 image]";
|
return "[base64 image]";
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}, 2);
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
}, [node]);
|
}, [node]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,14 +151,18 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
|||||||
{view === "info" ? (
|
{view === "info" ? (
|
||||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||||
<InfoRow label="ID" value={node.id} />
|
<InfoRow label="ID" value={node.id} />
|
||||||
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
|
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : "生成配置"} />
|
||||||
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
<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={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||||||
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
||||||
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
|
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
|
||||||
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
|
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
|
||||||
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
|
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
|
||||||
{node.metadata?.errorDetails ? <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>{node.metadata.errorDetails}</div> : null}
|
{node.metadata?.errorDetails ? (
|
||||||
|
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
|
||||||
|
{node.metadata.errorDetails}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
|
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowUp, LoaderCircle } from "lucide-react";
|
import { ArrowUp, LoaderCircle } from "lucide-react";
|
||||||
import { Button, InputNumber } from "antd";
|
import { Button } from "antd";
|
||||||
|
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
|
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||||
import { CanvasSizePicker } from "./canvas-size-picker";
|
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
||||||
|
|
||||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||||
@@ -20,10 +22,12 @@ type CanvasNodePromptPanelProps = {
|
|||||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
||||||
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
||||||
|
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
|
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||||
const globalConfig = useConfigStore((state) => state.config);
|
const globalConfig = useEffectiveConfig();
|
||||||
|
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const mode = defaultMode(node.type);
|
const mode = defaultMode(node.type);
|
||||||
@@ -32,6 +36,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
|||||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||||
const isEditingExistingContent = hasTextContent || hasImageContent;
|
const isEditingExistingContent = hasTextContent || hasImageContent;
|
||||||
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||||
|
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? config.count : 1 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||||
@@ -67,45 +72,66 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
|||||||
}}
|
}}
|
||||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||||
placeholder={mode === "image" ? hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容" : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
|
placeholder={mode === "video" ? "描述要生成的视频内容" : mode === "image" ? (hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容") : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
|
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||||
|
{mode === "image" ? (
|
||||||
|
<>
|
||||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||||
{mode === "image" ? (
|
<CanvasImageSettingsPopover
|
||||||
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} />
|
config={config}
|
||||||
) : null}
|
placement="topLeft"
|
||||||
{mode === "image" ? (
|
buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3"
|
||||||
<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 })} />
|
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||||
) : null}
|
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, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
className="!h-10 !min-w-16 shrink-0 !rounded-full !px-3"
|
||||||
className="!h-10 !w-10 !min-w-10 shrink-0"
|
|
||||||
disabled={isRunning || !prompt.trim()}
|
disabled={isRunning || !prompt.trim()}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
|
|
||||||
aria-label="生成"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
|
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
|
||||||
return type === CanvasNodeType.Text ? "text" : "image";
|
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
|
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
|
||||||
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
|
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
|
||||||
return {
|
return {
|
||||||
...globalConfig,
|
...globalConfig,
|
||||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||||
quality: globalConfig.quality || defaultConfig.quality,
|
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||||
|
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||||
|
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ChevronRight, Image as ImageIcon, RefreshCw, Star } from "lucide-react";
|
import { ChevronRight, Image as ImageIcon, RefreshCw, Star, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { formatBytes } from "@/lib/image-utils";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ type CanvasNodeProps = {
|
|||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
editRequestNonce?: number;
|
editRequestNonce?: number;
|
||||||
showPanel: boolean;
|
showPanel: boolean;
|
||||||
|
showImageInfo: boolean;
|
||||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
batchCount?: number;
|
batchCount?: number;
|
||||||
@@ -71,6 +73,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
isConnecting,
|
isConnecting,
|
||||||
editRequestNonce = 0,
|
editRequestNonce = 0,
|
||||||
showPanel,
|
showPanel,
|
||||||
|
showImageInfo,
|
||||||
renderPanel,
|
renderPanel,
|
||||||
renderNodeContent,
|
renderNodeContent,
|
||||||
batchCount = 0,
|
batchCount = 0,
|
||||||
@@ -95,6 +98,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||||
|
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||||
@@ -208,7 +212,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
startTop: data.position.y,
|
startTop: data.position.y,
|
||||||
startWidth: data.width,
|
startWidth: data.width,
|
||||||
startHeight: data.height,
|
startHeight: data.height,
|
||||||
keepRatio: data.type === CanvasNodeType.Image && !data.metadata?.freeResize,
|
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
||||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", handleResizeMove);
|
window.addEventListener("mousemove", handleResizeMove);
|
||||||
@@ -246,7 +250,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
<div
|
<div
|
||||||
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
||||||
style={{
|
style={{
|
||||||
background: hasImageContent ? "transparent" : theme.node.fill,
|
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||||
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
||||||
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
|
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -264,18 +268,16 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||||
style={{
|
style={
|
||||||
background: hasImageContent ? "transparent" : theme.node.fill,
|
{
|
||||||
|
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||||
animation: data.metadata?.batchRootId
|
animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined,
|
||||||
? batchClosing
|
|
||||||
? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both"
|
|
||||||
: "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both"
|
|
||||||
: undefined,
|
|
||||||
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
|
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<NodeContent
|
<NodeContent
|
||||||
node={data}
|
node={data}
|
||||||
@@ -297,12 +299,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasImageContent ? (
|
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
|
{!hasImageContent && !hasVideoContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||||
style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||||
@@ -310,16 +309,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionHandleDot
|
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
||||||
side="left"
|
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
||||||
visible={hovered || isSelected || isConnecting}
|
|
||||||
onMouseDown={(event) => onConnectStart(event, data.id, "target")}
|
|
||||||
/>
|
|
||||||
<ConnectionHandleDot
|
|
||||||
side="right"
|
|
||||||
visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)}
|
|
||||||
onMouseDown={(event) => onConnectStart(event, data.id, "source")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +331,7 @@ const nodeContentRenderers = {
|
|||||||
[CanvasNodeType.Text]: TextContent,
|
[CanvasNodeType.Text]: TextContent,
|
||||||
[CanvasNodeType.Image]: ImageNodeContent,
|
[CanvasNodeType.Image]: ImageNodeContent,
|
||||||
[CanvasNodeType.Config]: EmptyImageContent,
|
[CanvasNodeType.Config]: EmptyImageContent,
|
||||||
|
[CanvasNodeType.Video]: VideoNodeContent,
|
||||||
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
||||||
|
|
||||||
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||||
@@ -351,11 +343,7 @@ function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorContent({
|
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
||||||
node,
|
|
||||||
theme,
|
|
||||||
onRetry,
|
|
||||||
}: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
||||||
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
|
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
|
||||||
@@ -376,15 +364,7 @@ function ErrorContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextContent({
|
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||||
node,
|
|
||||||
theme,
|
|
||||||
isEditingContent,
|
|
||||||
textareaRef,
|
|
||||||
onContentChange,
|
|
||||||
onStopEditing,
|
|
||||||
onGenerateImage,
|
|
||||||
}: NodeContentRendererProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||||
<button
|
<button
|
||||||
@@ -433,12 +413,19 @@ function TextContent({
|
|||||||
|
|
||||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||||
const content = props.node.metadata?.status === "loading"
|
const content =
|
||||||
? <LoadingContent theme={props.theme} />
|
props.node.metadata?.status === "loading" ? (
|
||||||
: props.node.metadata?.status === "error"
|
<LoadingContent theme={props.theme} />
|
||||||
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
|
) : props.node.metadata?.status === "error" ? (
|
||||||
: <EmptyImageContent {...props} isBatchRoot={false} />;
|
<ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
|
||||||
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
|
) : (
|
||||||
|
<EmptyImageContent {...props} isBatchRoot={false} />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>
|
||||||
|
{content}
|
||||||
|
</BatchFrame>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
|
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
|
||||||
|
|
||||||
@@ -465,10 +452,24 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
|
|||||||
<span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>
|
<span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>{content}</BatchFrame>;
|
if (isBatchRoot)
|
||||||
return (
|
return (
|
||||||
content
|
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
|
||||||
|
{content}
|
||||||
|
</BatchFrame>
|
||||||
);
|
);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||||
|
if (!node.metadata?.content)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
|
||||||
|
<Video className="size-7 opacity-35" />
|
||||||
|
<span className="text-sm">视频节点(未开发完,请勿使用)</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageContent({
|
function ImageContent({
|
||||||
@@ -541,11 +542,35 @@ function ImageContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImageInfoBar({ node }: { node: CanvasNodeData }) {
|
||||||
|
const width = Math.round(node.metadata?.naturalWidth || node.width);
|
||||||
|
const height = Math.round(node.metadata?.naturalHeight || node.height);
|
||||||
|
const size = formatBytes(node.metadata?.bytes || 0);
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute bottom-3 right-3 z-40 max-w-[calc(100%-24px)]">
|
||||||
|
<span className="max-w-full truncate rounded-md bg-black/55 px-2 py-1 text-[11px] font-medium leading-none text-white backdrop-blur-sm">
|
||||||
|
{width} x {height}
|
||||||
|
{size ? ` · ${size}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
|
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const isBatchRoot = batchCount > 1;
|
const isBatchRoot = batchCount > 1;
|
||||||
return (
|
return (
|
||||||
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}>
|
<div
|
||||||
|
className="group/batch relative h-full w-full overflow-visible"
|
||||||
|
onDoubleClick={
|
||||||
|
isBatchRoot
|
||||||
|
? (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleBatch?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{isBatchRoot ? (
|
{isBatchRoot ? (
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-visible">
|
<div className="pointer-events-none absolute inset-0 overflow-visible">
|
||||||
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
|
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
|
||||||
@@ -557,7 +582,8 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
|
|||||||
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
|
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
|
||||||
borderColor: theme.node.stroke,
|
borderColor: theme.node.stroke,
|
||||||
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
|
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
|
||||||
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
|
transform:
|
||||||
|
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
|
||||||
zIndex: -index - 1,
|
zIndex: -index - 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -568,13 +594,7 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function ResizeHandle({
|
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
|
||||||
corner,
|
|
||||||
onMouseDown,
|
|
||||||
}: {
|
|
||||||
corner: ResizeCorner;
|
|
||||||
onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void;
|
|
||||||
}) {
|
|
||||||
const positionClass = {
|
const positionClass = {
|
||||||
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
|
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
|
||||||
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
|
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
|
||||||
@@ -582,23 +602,10 @@ function ResizeHandle({
|
|||||||
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
|
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
|
||||||
}[corner];
|
}[corner];
|
||||||
|
|
||||||
return (
|
return <div className={`absolute z-50 size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
|
||||||
<div
|
|
||||||
className={`absolute z-50 size-7 ${positionClass}`}
|
|
||||||
onMouseDown={(event) => onMouseDown(event, corner)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionHandleDot({
|
function ConnectionHandleDot({ side, visible, onMouseDown }: { side: "left" | "right"; visible: boolean; onMouseDown: (event: React.MouseEvent) => void }) {
|
||||||
side,
|
|
||||||
visible,
|
|
||||||
onMouseDown,
|
|
||||||
}: {
|
|
||||||
side: "left" | "right";
|
|
||||||
visible: boolean;
|
|
||||||
onMouseDown: (event: React.MouseEvent) => void;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -608,10 +615,7 @@ function ConnectionHandleDot({
|
|||||||
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
>
|
>
|
||||||
<div
|
<div className="size-3 rounded-full border-2 transition-all hover:scale-125" style={{ background: theme.node.panel, borderColor: theme.node.muted }} />
|
||||||
className="size-3 rounded-full border-2 transition-all hover:scale-125"
|
|
||||||
style={{ background: theme.node.panel, borderColor: theme.node.muted }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,18 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="min-w-0 cursor-pointer text-left" onClick={(event) => { event.stopPropagation(); open(); }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 cursor-pointer text-left"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
|
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
|
||||||
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} 个节点 · {project.connections.length} 条连线</p>
|
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
|
||||||
|
{project.nodes.length} 个节点 · {project.connections.length} 条连线
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) =
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="提示词库">
|
<Tooltip title="提示词库">
|
||||||
<Button shape="circle" className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent" style={{ borderColor: theme.node.stroke, color: theme.node.text }} icon={<BookOpen className="size-3.5" />} onClick={() => setOpen(true)} aria-label="提示词库" />
|
<Button
|
||||||
|
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>
|
</Tooltip>
|
||||||
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
|
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
|
|||||||
placeholder="比例"
|
placeholder="比例"
|
||||||
options={options}
|
options={options}
|
||||||
popupMatchSelectWidth={false}
|
popupMatchSelectWidth={false}
|
||||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
popupRender={(menu) => (
|
||||||
|
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
onChange={selectSize}
|
onChange={selectSize}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Button, Segmented } from "antd";
|
import { Button, Segmented, Switch } 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 { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, 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 { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
@@ -12,7 +12,9 @@ export function CanvasToolbar({
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
backgroundMode,
|
backgroundMode,
|
||||||
|
showImageInfo,
|
||||||
onAddImage,
|
onAddImage,
|
||||||
|
onAddVideo,
|
||||||
onAddText,
|
onAddText,
|
||||||
onAddConfig,
|
onAddConfig,
|
||||||
onUndo,
|
onUndo,
|
||||||
@@ -22,6 +24,7 @@ export function CanvasToolbar({
|
|||||||
onClear,
|
onClear,
|
||||||
onDeselect,
|
onDeselect,
|
||||||
onBackgroundModeChange,
|
onBackgroundModeChange,
|
||||||
|
onShowImageInfoChange,
|
||||||
onOpenAssetLibrary,
|
onOpenAssetLibrary,
|
||||||
onOpenMyAssets,
|
onOpenMyAssets,
|
||||||
}: {
|
}: {
|
||||||
@@ -29,7 +32,9 @@ export function CanvasToolbar({
|
|||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
backgroundMode: CanvasBackgroundMode;
|
backgroundMode: CanvasBackgroundMode;
|
||||||
|
showImageInfo: boolean;
|
||||||
onAddImage: () => void;
|
onAddImage: () => void;
|
||||||
|
onAddVideo: () => void;
|
||||||
onAddText: () => void;
|
onAddText: () => void;
|
||||||
onAddConfig: () => void;
|
onAddConfig: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
@@ -39,6 +44,7 @@ export function CanvasToolbar({
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||||
|
onShowImageInfoChange: (show: boolean) => void;
|
||||||
onOpenAssetLibrary: () => void;
|
onOpenAssetLibrary: () => void;
|
||||||
onOpenMyAssets: () => void;
|
onOpenMyAssets: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -58,11 +64,7 @@ export function CanvasToolbar({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||||
<div
|
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
||||||
ref={wrapRef}
|
|
||||||
className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0"
|
|
||||||
style={dockStyle}
|
|
||||||
>
|
|
||||||
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||||
<Hand className="size-4.5" />
|
<Hand className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -79,6 +81,9 @@ export function CanvasToolbar({
|
|||||||
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
|
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
|
||||||
<ImageIcon className="size-4.5" />
|
<ImageIcon className="size-4.5" />
|
||||||
</ToolbarButton>
|
</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-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
||||||
<Settings2 className="size-4.5" />
|
<Settings2 className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -146,14 +151,42 @@ export function CanvasToolbar({
|
|||||||
value={backgroundMode}
|
value={backgroundMode}
|
||||||
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
|
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-4" />点</span> },
|
{
|
||||||
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-4" />线</span> },
|
value: "dots",
|
||||||
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" />空白</span> },
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<CircleDot className="size-4" />点
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "lines",
|
||||||
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Grid2x2 className="size-4" />线
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "blank",
|
||||||
|
label: (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Square className="size-4" />
|
||||||
|
空白
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<div 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,7 +230,10 @@ function ToolbarButton({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
|
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
|
||||||
icon={children}
|
icon={children}
|
||||||
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }}
|
onMouseEnter={(event) => {
|
||||||
|
onHover(id);
|
||||||
|
onTipX(getTipX(wrapRef.current, event.currentTarget));
|
||||||
|
}}
|
||||||
onMouseLeave={() => onHover(null)}
|
onMouseLeave={() => onHover(null)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
/>
|
/>
|
||||||
@@ -208,17 +244,7 @@ function Divider({ theme }: { theme: CanvasTheme }) {
|
|||||||
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
|
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasThemeButton({
|
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
|
||||||
colorTheme,
|
|
||||||
targetTheme,
|
|
||||||
onThemeChange,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
colorTheme: CanvasColorTheme;
|
|
||||||
targetTheme: CanvasColorTheme;
|
|
||||||
onThemeChange: (theme: CanvasColorTheme) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const theme = canvasThemes[colorTheme];
|
const theme = canvasThemes[colorTheme];
|
||||||
const active = colorTheme === targetTheme;
|
const active = colorTheme === targetTheme;
|
||||||
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||||
@@ -239,7 +265,11 @@ function CanvasThemeButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) {
|
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) {
|
||||||
return <span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>{label}</span>;
|
return (
|
||||||
|
<span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolLabel(id: string) {
|
function toolLabel(id: string) {
|
||||||
@@ -248,6 +278,7 @@ function toolLabel(id: string) {
|
|||||||
if (id === "tool-redo") return "重做";
|
if (id === "tool-redo") return "重做";
|
||||||
if (id === "tool-text") return "文本";
|
if (id === "tool-text") return "文本";
|
||||||
if (id === "tool-image") return "图片";
|
if (id === "tool-image") return "图片";
|
||||||
|
if (id === "tool-video") return "视频";
|
||||||
if (id === "tool-config") return "生成配置";
|
if (id === "tool-config") return "生成配置";
|
||||||
if (id === "tool-upload") return "上传图片";
|
if (id === "tool-upload") return "上传图片";
|
||||||
if (id === "tool-library") return "素材库";
|
if (id === "tool-library") return "素材库";
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
import { Button, Popover } from "antd";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { CanvasImageSettingsTheme } from "./canvas-image-settings-popover";
|
||||||
|
|
||||||
|
const resolutionOptions = [
|
||||||
|
{ value: "720", label: "720p" },
|
||||||
|
{ value: "480", label: "480p" },
|
||||||
|
];
|
||||||
|
const sizeOptions = [
|
||||||
|
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||||
|
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
|
||||||
|
{ value: "1024x1024", label: "方形", width: 1024, height: 1024 },
|
||||||
|
{ value: "1792x1024", label: "宽屏", width: 1792, height: 1024 },
|
||||||
|
{ value: "1024x1792", label: "长图", width: 1024, height: 1792 },
|
||||||
|
];
|
||||||
|
const secondOptions = [6, 10, 12, 16, 20];
|
||||||
|
|
||||||
|
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 seconds = config.videoSeconds || "6";
|
||||||
|
const size = normalizeVideoSize(config.size);
|
||||||
|
const dimensions = readSizeDimensions(size);
|
||||||
|
const resolution = normalizeVideoResolution(config.vquality);
|
||||||
|
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||||
|
const next = Math.max(1, Math.floor(value || dimensions[key] || 720));
|
||||||
|
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement={placement}
|
||||||
|
arrow={false}
|
||||||
|
overlayClassName="canvas-image-settings-popover"
|
||||||
|
color={theme.toolbar.panel}
|
||||||
|
zIndex={1200}
|
||||||
|
getPopupContainer={() => document.body}
|
||||||
|
content={
|
||||||
|
<CanvasImageSettingsTheme theme={theme}>
|
||||||
|
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<div className="text-xl font-semibold">视频设置</div>
|
||||||
|
<SettingGroup title="清晰度" color={theme.node.muted}>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{resolutionOptions.map((item) => (
|
||||||
|
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||||
|
{item.label}
|
||||||
|
</OptionPill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ResolutionInput value={resolution} theme={theme} onChange={(value) => onConfigChange("vquality", value)} />
|
||||||
|
</SettingGroup>
|
||||||
|
<SettingGroup title="尺寸" color={theme.node.muted}>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||||
|
<DimensionInput prefix="W" value={dimensions.width} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||||
|
<span className="text-lg opacity-45">↔</span>
|
||||||
|
<DimensionInput prefix="H" value={dimensions.height} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{sizeOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
className="flex h-[82px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-xs transition hover:opacity-80"
|
||||||
|
style={{ borderColor: size === item.value ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={() => onConfigChange("size", item.value)}
|
||||||
|
>
|
||||||
|
<SizePreview width={item.width} height={item.height} color={theme.node.text} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingGroup>
|
||||||
|
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{secondOptions.map((value) => (
|
||||||
|
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
|
||||||
|
{value}s
|
||||||
|
</OptionPill>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={6}
|
||||||
|
max={20}
|
||||||
|
className="col-span-2 h-10 rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||||
|
value={seconds}
|
||||||
|
onChange={(event) => onConfigChange("videoSeconds", event.target.value)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingGroup>
|
||||||
|
</div>
|
||||||
|
</CanvasImageSettingsTheme>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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" />}>
|
||||||
|
<span className="truncate">
|
||||||
|
{resolution}p · {sizeLabel(size)} · {seconds}s
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80" style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs font-medium" style={{ color }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResolutionInput({ value, theme, onChange }: { value: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: string) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="flex h-10 overflow-hidden rounded-xl border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="min-w-0 flex-1 bg-transparent px-3 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||||
|
p
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DimensionInput({ prefix, value, theme, onChange }: { prefix: string; value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text }}>
|
||||||
|
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SizePreview({ width, height, color }: { width: number; height: number; color: string }) {
|
||||||
|
const longSide = Math.max(width, height);
|
||||||
|
const previewWidth = Math.max(12, Math.round((width / longSide) * 34));
|
||||||
|
const previewHeight = Math.max(12, Math.round((height / longSide) * 34));
|
||||||
|
return <span className="rounded-[4px] border" style={{ width: previewWidth, height: previewHeight, borderColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVideoSize(value: string) {
|
||||||
|
if (/^\d+x\d+$/.test(value || "")) return value;
|
||||||
|
return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVideoResolution(value: string) {
|
||||||
|
if (value === "480p" || value === "low") return "480";
|
||||||
|
if (value === "720p" || value === "auto" || value === "high" || value === "medium") return "720";
|
||||||
|
return value.replace(/p$/i, "") || "720";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSizeDimensions(size: string) {
|
||||||
|
const match = size.match(/^(\d+)x(\d+)$/);
|
||||||
|
return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeLabel(value: string) {
|
||||||
|
return ({ "720x1280": "竖屏", "1280x720": "横屏", "1024x1024": "方形", "1792x1024": "宽屏", "1024x1792": "长图" } as Record<string, string>)[value] || value;
|
||||||
|
}
|
||||||
@@ -25,7 +25,14 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
|||||||
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||||
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
|
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
|
||||||
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
|
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
|
||||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} icon={<Compass className="size-4" />} onClick={onToggleMiniMap} aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="!h-8 !w-8 !min-w-8 !p-0"
|
||||||
|
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
|
||||||
|
icon={<Compass className="size-4" />}
|
||||||
|
onClick={onToggleMiniMap}
|
||||||
|
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="重置视图">
|
<Tooltip title="重置视图">
|
||||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
|
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
|
||||||
|
|||||||
@@ -18,17 +18,7 @@ type InfiniteCanvasProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InfiniteCanvas({
|
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
|
||||||
containerRef,
|
|
||||||
viewport,
|
|
||||||
backgroundMode = "lines",
|
|
||||||
onViewportChange,
|
|
||||||
onCanvasMouseDown,
|
|
||||||
onCanvasDeselect,
|
|
||||||
onContextMenu,
|
|
||||||
onDrop,
|
|
||||||
children,
|
|
||||||
}: InfiniteCanvasProps) {
|
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const panState = useRef({
|
const panState = useRef({
|
||||||
isPanning: false,
|
isPanning: false,
|
||||||
@@ -47,9 +37,12 @@ export function InfiniteCanvas({
|
|||||||
scaleRef.current = viewport.k;
|
scaleRef.current = viewport.k;
|
||||||
}, [viewport.k]);
|
}, [viewport.k]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(
|
||||||
|
() => () => {
|
||||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -72,7 +65,7 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
if (target?.closest(".ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
||||||
|
|
||||||
const delta = -event.deltaY;
|
const delta = -event.deltaY;
|
||||||
const factor = Math.pow(1.1, delta / 100);
|
const factor = Math.pow(1.1, delta / 100);
|
||||||
@@ -94,6 +87,7 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
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;
|
if (target?.closest("[data-connection-create-menu]")) return;
|
||||||
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
|
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
|
||||||
|
|
||||||
@@ -205,9 +199,8 @@ function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: Can
|
|||||||
const x = viewport.x % gridSize;
|
const x = viewport.x % gridSize;
|
||||||
const y = viewport.y % gridSize;
|
const y = viewport.y % gridSize;
|
||||||
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
|
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
|
||||||
const backgroundImage = mode === "dots"
|
const backgroundImage =
|
||||||
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)`
|
mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
|
||||||
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const NODE_DEFAULT_SIZE = {
|
|||||||
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
|
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
|
||||||
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
|
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
|
||||||
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
||||||
|
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
|
||||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||||
|
|
||||||
export const NODE_SPECS = {
|
export const NODE_SPECS = {
|
||||||
@@ -27,6 +28,10 @@ export const NODE_SPECS = {
|
|||||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
|
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
|
||||||
metadata: { content: "", status: "idle", generationMode: "image" },
|
metadata: { content: "", status: "idle", generationMode: "image" },
|
||||||
},
|
},
|
||||||
|
[CanvasNodeType.Video]: {
|
||||||
|
...NODE_DEFAULT_SIZE[CanvasNodeType.Video],
|
||||||
|
metadata: { content: "", status: "idle" },
|
||||||
|
},
|
||||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||||
|
|
||||||
export function getNodeSpec(type: CanvasNodeType) {
|
export function getNodeSpec(type: CanvasNodeType) {
|
||||||
|
|||||||
@@ -54,10 +54,22 @@ export default function CanvasPage() {
|
|||||||
<h1 className="mt-3 text-3xl font-semibold">无限画布</h1>
|
<h1 className="mt-3 text-3xl font-semibold">无限画布</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除选中</Button> : null}
|
{selectedIds.length ? (
|
||||||
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>删除全部</Button> : null}
|
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
|
||||||
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>导入画布</Button>
|
删除选中
|
||||||
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{projects.length ? (
|
||||||
|
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
|
||||||
|
删除全部
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>
|
||||||
|
导入画布
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||||
|
新建画布
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -73,7 +85,9 @@ export default function CanvasPage() {
|
|||||||
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
|
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
|
||||||
<h2 className="text-xl font-medium">还没有画布</h2>
|
<h2 className="text-xl font-medium">还没有画布</h2>
|
||||||
<p className="mt-3 text-sm text-stone-500">新建一个画布后,就可以独立保存节点、连线和画布外观。</p>
|
<p className="mt-3 text-sm text-stone-500">新建一个画布后,就可以独立保存节点、连线和画布外观。</p>
|
||||||
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>
|
||||||
|
新建画布
|
||||||
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type CanvasProject = {
|
|||||||
chatSessions: CanvasAssistantSession[];
|
chatSessions: CanvasAssistantSession[];
|
||||||
activeChatId: string | null;
|
activeChatId: string | null;
|
||||||
backgroundMode: CanvasBackgroundMode;
|
backgroundMode: CanvasBackgroundMode;
|
||||||
|
showImageInfo: boolean;
|
||||||
viewport: ViewportTransform;
|
viewport: ViewportTransform;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ type CanvasStore = {
|
|||||||
openProject: (id: string) => CanvasProject | null;
|
openProject: (id: string) => CanvasProject | null;
|
||||||
renameProject: (id: string, title: string) => void;
|
renameProject: (id: string, title: string) => void;
|
||||||
deleteProjects: (ids: string[]) => void;
|
deleteProjects: (ids: string[]) => void;
|
||||||
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void;
|
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport">>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
|
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
|
||||||
@@ -75,6 +76,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
activeChatId: null,
|
activeChatId: null,
|
||||||
backgroundMode: "lines",
|
backgroundMode: "lines",
|
||||||
|
showImageInfo: false,
|
||||||
viewport: initialViewport,
|
viewport: initialViewport,
|
||||||
};
|
};
|
||||||
set((state) => ({ projects: [project, ...state.projects] }));
|
set((state) => ({ projects: [project, ...state.projects] }));
|
||||||
@@ -92,6 +94,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
chatSessions: source.chatSessions || [],
|
chatSessions: source.chatSessions || [],
|
||||||
activeChatId: source.activeChatId || null,
|
activeChatId: source.activeChatId || null,
|
||||||
backgroundMode: source.backgroundMode || "lines",
|
backgroundMode: source.backgroundMode || "lines",
|
||||||
|
showImageInfo: source.showImageInfo || false,
|
||||||
viewport: source.viewport || initialViewport,
|
viewport: source.viewport || initialViewport,
|
||||||
};
|
};
|
||||||
set((state) => ({ projects: [project, ...state.projects] }));
|
set((state) => ({ projects: [project, ...state.projects] }));
|
||||||
@@ -100,21 +103,25 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
openProject: (id) => {
|
openProject: (id) => {
|
||||||
return get().projects.find((item) => item.id === id) || null;
|
return get().projects.find((item) => item.id === id) || null;
|
||||||
},
|
},
|
||||||
renameProject: (id, title) => set((state) => ({
|
renameProject: (id, title) =>
|
||||||
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project),
|
set((state) => ({
|
||||||
|
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
|
||||||
})),
|
})),
|
||||||
deleteProjects: (ids) => set((state) => {
|
deleteProjects: (ids) =>
|
||||||
|
set((state) => {
|
||||||
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
||||||
return { projects };
|
return { projects };
|
||||||
}),
|
}),
|
||||||
updateProject: (id, patch) => set((state) => ({
|
updateProject: (id, patch) =>
|
||||||
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project),
|
set((state) => ({
|
||||||
|
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: CANVAS_STORE_KEY,
|
name: CANVAS_STORE_KEY,
|
||||||
storage: canvasStorage,
|
storage: canvasStorage,
|
||||||
partialize: (state) => ({
|
partialize: (state) =>
|
||||||
|
({
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
}) as StorageValue<CanvasStore>["state"],
|
}) as StorageValue<CanvasStore>["state"],
|
||||||
onRehydrateStorage: () => () => {
|
onRehydrateStorage: () => () => {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ export enum CanvasNodeType {
|
|||||||
Image = "image",
|
Image = "image",
|
||||||
Text = "text",
|
Text = "text",
|
||||||
Config = "config",
|
Config = "config",
|
||||||
|
Video = "video",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
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 CanvasImageGenerationType = "generation" | "edit";
|
||||||
|
|
||||||
export type CanvasNodeMetadata = {
|
export type CanvasNodeMetadata = {
|
||||||
@@ -31,6 +32,8 @@ export type CanvasNodeMetadata = {
|
|||||||
size?: string;
|
size?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
seconds?: string;
|
||||||
|
vquality?: string;
|
||||||
references?: string[];
|
references?: string[];
|
||||||
naturalWidth?: number;
|
naturalWidth?: number;
|
||||||
naturalHeight?: number;
|
naturalHeight?: number;
|
||||||
@@ -111,8 +114,7 @@ export type SelectionBox = {
|
|||||||
initialSelectedNodeIds: string[];
|
initialSelectedNodeIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContextMenuState =
|
export type ContextMenuState = {
|
||||||
{
|
|
||||||
type: "node";
|
type: "node";
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -17,13 +17,7 @@ export type ImageAngleTransform = {
|
|||||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||||
const image = await loadImage(dataUrl);
|
const image = await loadImage(dataUrl);
|
||||||
if (crop) {
|
if (crop) {
|
||||||
return drawCrop(
|
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
|
||||||
image,
|
|
||||||
Math.floor(crop.x * image.width),
|
|
||||||
Math.floor(crop.y * image.height),
|
|
||||||
Math.ceil(crop.width * image.width),
|
|
||||||
Math.ceil(crop.height * image.height),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const size = Math.min(image.width, image.height);
|
const size = Math.min(image.width, image.height);
|
||||||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -51,7 +51,12 @@ type GenerationLog = {
|
|||||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||||
|
|
||||||
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
|
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
|
||||||
const qualityOptions = ["auto", "low", "medium", "high"].map((value) => ({ label: value, value }));
|
const qualityOptions = [
|
||||||
|
{ label: "auto", value: "auto" },
|
||||||
|
{ label: "low / 1K", value: "low" },
|
||||||
|
{ label: "medium / 2K", value: "medium" },
|
||||||
|
{ label: "high / 4K", value: "high" },
|
||||||
|
];
|
||||||
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
||||||
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
|
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
|
||||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
||||||
@@ -96,10 +101,12 @@ export default function ImagePage() {
|
|||||||
|
|
||||||
const addReferences = async (files?: FileList | null) => {
|
const addReferences = async (files?: FileList | null) => {
|
||||||
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||||
const nextReferences = await Promise.all(imageFiles.map(async (file) => {
|
const nextReferences = await Promise.all(
|
||||||
|
imageFiles.map(async (file) => {
|
||||||
const image = await uploadImage(file);
|
const image = await uploadImage(file);
|
||||||
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
setReferences((value) => [...value, ...nextReferences]);
|
setReferences((value) => [...value, ...nextReferences]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,10 +118,12 @@ export default function ImagePage() {
|
|||||||
message.error("剪切板里没有可读取的图片");
|
message.error("剪切板里没有可读取的图片");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextReferences = await Promise.all(blobs.map(async (blob, index) => {
|
const nextReferences = await Promise.all(
|
||||||
|
blobs.map(async (blob, index) => {
|
||||||
const image = await uploadImage(blob);
|
const image = await uploadImage(blob);
|
||||||
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
setReferences((value) => [...value, ...nextReferences]);
|
setReferences((value) => [...value, ...nextReferences]);
|
||||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,15 +156,24 @@ export default function ImagePage() {
|
|||||||
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
|
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
|
||||||
|
|
||||||
const result = await Promise.allSettled(tasks);
|
const result = await Promise.allSettled(tasks);
|
||||||
const successImages = result
|
const successImages = result.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled").map((item) => item.value);
|
||||||
.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled")
|
|
||||||
.map((item) => item.value);
|
|
||||||
const successCount = successImages.length;
|
const successCount = successImages.length;
|
||||||
const failCount = generationCount - successCount;
|
const failCount = generationCount - successCount;
|
||||||
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) }));
|
saveLog(
|
||||||
|
buildLog({
|
||||||
|
prompt: text,
|
||||||
|
model,
|
||||||
|
config: { ...snapshot.config, count: String(generationCount) },
|
||||||
|
durationMs: performance.now() - batchStartedAt,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
status: successCount ? "成功" : "失败",
|
||||||
|
thumbnails: successImages.map((image) => image.dataUrl),
|
||||||
|
}),
|
||||||
|
);
|
||||||
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -228,10 +246,12 @@ export default function ImagePage() {
|
|||||||
const previewGenerationLog = async (log: GenerationLog) => {
|
const previewGenerationLog = async (log: GenerationLog) => {
|
||||||
setPreviewLog(log);
|
setPreviewLog(log);
|
||||||
setLogsOpen(false);
|
setLogsOpen(false);
|
||||||
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => {
|
const images = await Promise.all(
|
||||||
|
log.thumbnails.map(async (dataUrl, index) => {
|
||||||
const meta = await readImageMeta(dataUrl);
|
const meta = await readImageMeta(dataUrl);
|
||||||
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
|
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
|
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,7 +297,15 @@ export default function ImagePage() {
|
|||||||
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
|
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
|
||||||
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
|
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
|
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
|
||||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
<LogPanel
|
||||||
|
logs={logs}
|
||||||
|
selectedLogIds={selectedLogIds}
|
||||||
|
activeLogId={previewLog?.id}
|
||||||
|
onSelectedLogIdsChange={setSelectedLogIds}
|
||||||
|
onCreateSession={createSession}
|
||||||
|
onDeleteSelected={() => setDeleteConfirmOpen(true)}
|
||||||
|
onPreviewLog={(log) => void previewGenerationLog(log)}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
|
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||||
@@ -288,8 +316,12 @@ export default function ImagePage() {
|
|||||||
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">生图工作台</h1>
|
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">生图工作台</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2 lg:hidden">
|
<div className="flex shrink-0 gap-2 lg:hidden">
|
||||||
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>记录</Button>
|
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
|
||||||
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>参数</Button>
|
记录
|
||||||
|
</Button>
|
||||||
|
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||||
|
参数
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,35 +331,46 @@ export default function ImagePage() {
|
|||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<span className="text-base font-semibold">提示词</span>
|
<span className="text-base font-semibold">提示词</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>查看提示词库</Button>
|
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>查看我的素材</Button>
|
查看提示词库
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||||
|
查看我的素材
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input.TextArea
|
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述画面主体、风格、构图、光线和用途" />
|
||||||
value={prompt}
|
|
||||||
onChange={(event) => setPrompt(event.target.value)}
|
|
||||||
rows={7}
|
|
||||||
placeholder="描述画面主体、风格、构图、光线和用途"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<span className="text-base font-semibold">参考图</span>
|
<span className="text-base font-semibold">参考图</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>剪切板</Button>
|
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
|
||||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>上传</Button>
|
剪切板
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
上传
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => {
|
<div
|
||||||
|
className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700"
|
||||||
|
onWheel={(event) => {
|
||||||
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
|
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.scrollLeft += event.deltaY;
|
event.currentTarget.scrollLeft += event.deltaY;
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{references.map((item) => (
|
{references.map((item) => (
|
||||||
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
|
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
|
||||||
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
|
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
|
||||||
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex"
|
||||||
|
onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))}
|
||||||
|
aria-label="移除参考图"
|
||||||
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,8 +380,12 @@ export default function ImagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||||
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {effectiveConfig.size} · {effectiveConfig.quality}</span>
|
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>调整</Button>
|
{model} · {effectiveConfig.size} · {effectiveConfig.quality}
|
||||||
|
</span>
|
||||||
|
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||||
|
调整
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
|
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
|
||||||
@@ -362,15 +409,15 @@ export default function ImagePage() {
|
|||||||
</div>
|
</div>
|
||||||
{results.length ? (
|
{results.length ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) =>
|
||||||
result.status === "success" && result.image ? (
|
result.status === "success" && result.image ? (
|
||||||
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
|
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
|
||||||
) : result.status === "failed" ? (
|
) : result.status === "failed" ? (
|
||||||
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
|
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
|
||||||
) : (
|
) : (
|
||||||
<PendingImageCard key={result.id} />
|
<PendingImageCard key={result.id} />
|
||||||
)
|
),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
|
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
|
||||||
@@ -381,12 +428,27 @@ export default function ImagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => {
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
void addReferences(event.target.files);
|
void addReferences(event.target.files);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
|
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
|
||||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
<LogPanel
|
||||||
|
logs={logs}
|
||||||
|
selectedLogIds={selectedLogIds}
|
||||||
|
activeLogId={previewLog?.id}
|
||||||
|
onSelectedLogIdsChange={setSelectedLogIds}
|
||||||
|
onCreateSession={createSession}
|
||||||
|
onDeleteSelected={() => setDeleteConfirmOpen(true)}
|
||||||
|
onPreviewLog={(log) => void previewGenerationLog(log)}
|
||||||
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -425,20 +487,40 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void }) {
|
function ResultImageCard({
|
||||||
|
image,
|
||||||
|
index,
|
||||||
|
onEdit,
|
||||||
|
onDownload,
|
||||||
|
onSaveAsset,
|
||||||
|
}: {
|
||||||
|
image: GeneratedImage;
|
||||||
|
index: number;
|
||||||
|
onEdit: (image: GeneratedImage, index: number) => void;
|
||||||
|
onDownload: (image: GeneratedImage, index: number) => void;
|
||||||
|
onSaveAsset: (image: GeneratedImage, index: number) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||||
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
|
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
||||||
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
|
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
|
||||||
<span>{image.width}x{image.height}</span>
|
<span>
|
||||||
|
{image.width}x{image.height}
|
||||||
|
</span>
|
||||||
<span>{formatBytes(image.bytes)}</span>
|
<span>{formatBytes(image.bytes)}</span>
|
||||||
<span>{formatDuration(image.durationMs)}</span>
|
<span>{formatDuration(image.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>添加到素材</Button>
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||||
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>加入参考图</Button>
|
添加到素材
|
||||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>下载</Button>
|
</Button>
|
||||||
|
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>
|
||||||
|
加入参考图
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,17 +555,35 @@ function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => voi
|
|||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
|
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
|
||||||
<Button size="small" danger onClick={onRetry}>重试</Button>
|
<Button size="small" danger onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
|
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
|
||||||
return results.map((item, itemIndex) => itemIndex === index ? { ...item, ...next } : item);
|
return results.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void }) {
|
function LogPanel({
|
||||||
|
logs,
|
||||||
|
selectedLogIds,
|
||||||
|
activeLogId,
|
||||||
|
onSelectedLogIdsChange,
|
||||||
|
onCreateSession,
|
||||||
|
onDeleteSelected,
|
||||||
|
onPreviewLog,
|
||||||
|
}: {
|
||||||
|
logs: GenerationLog[];
|
||||||
|
selectedLogIds: string[];
|
||||||
|
activeLogId?: string;
|
||||||
|
onSelectedLogIdsChange: (ids: string[]) => void;
|
||||||
|
onCreateSession: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
onPreviewLog: (log: GenerationLog) => void;
|
||||||
|
}) {
|
||||||
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
|
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
|
||||||
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
|
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
|
||||||
|
|
||||||
@@ -496,9 +596,15 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
|||||||
<Tag className="m-0">{logs.length}</Tag>
|
<Tag className="m-0">{logs.length}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>新建</Button>
|
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
|
||||||
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
|
新建
|
||||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>删除</Button>
|
</Button>
|
||||||
|
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
|
||||||
|
{allSelected ? "取消" : "全选"}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
@@ -511,11 +617,7 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
|||||||
onClick={() => onPreviewLog(log)}
|
onClick={() => onPreviewLog(log)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!logs.length ? (
|
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">暂无生成记录</div> : null}
|
||||||
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
|
|
||||||
暂无生成记录
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -523,7 +625,11 @@ function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, o
|
|||||||
|
|
||||||
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
|
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
|
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
|
||||||
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
|
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
|
||||||
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
|
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
|
||||||
@@ -531,19 +637,29 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
|
|||||||
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
|
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
|
||||||
{log.thumbnails?.length ? (
|
{log.thumbnails?.length ? (
|
||||||
<div className="mt-2 flex gap-1 overflow-hidden">
|
<div className="mt-2 flex gap-1 overflow-hidden">
|
||||||
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)}
|
{log.thumbnails.slice(0, 4).map((image, index) => (
|
||||||
|
<img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid justify-items-end gap-2">
|
<div className="grid justify-items-end gap-2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">成功 {log.successCount ?? log.imageCount}</Tag>
|
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">
|
||||||
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">失败 {log.failCount}</Tag> : null}
|
成功 {log.successCount ?? log.imageCount}
|
||||||
|
</Tag>
|
||||||
|
{log.failCount ? (
|
||||||
|
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">
|
||||||
|
失败 {log.failCount}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-end gap-1">
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} 张</Tag>
|
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} 张</Tag>
|
||||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag>
|
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
|
||||||
|
{formatDuration(log.durationMs)}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
|
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
|
||||||
@@ -563,20 +679,21 @@ async function readStoredLogs() {
|
|||||||
window.localStorage.removeItem(LOG_STORE_KEY);
|
window.localStorage.removeItem(LOG_STORE_KEY);
|
||||||
}
|
}
|
||||||
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
|
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
|
||||||
await Promise.all(legacyKeys.map(async (key) => {
|
await Promise.all(
|
||||||
|
legacyKeys.map(async (key) => {
|
||||||
try {
|
try {
|
||||||
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
|
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
|
||||||
await logStore.setItem(log.id, log);
|
await logStore.setItem(log.id, log);
|
||||||
window.localStorage.removeItem(key);
|
window.localStorage.removeItem(key);
|
||||||
} catch {}
|
} catch {}
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const logs: GenerationLog[] = [];
|
const logs: GenerationLog[] = [];
|
||||||
await logStore.iterate<GenerationLog, void>((value) => {
|
await logStore.iterate<GenerationLog, void>((value) => {
|
||||||
logs.push(normalizeLog(value));
|
logs.push(normalizeLog(value));
|
||||||
});
|
});
|
||||||
return logs
|
return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||||
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -600,7 +717,25 @@ function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog {
|
function buildLog({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
config,
|
||||||
|
durationMs,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
status,
|
||||||
|
thumbnails,
|
||||||
|
}: {
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
config: { size: string; quality: string; count?: string };
|
||||||
|
durationMs: number;
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
status: GenerationLog["status"];
|
||||||
|
thumbnails: string[];
|
||||||
|
}): GenerationLog {
|
||||||
return {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { App, Button, Form, Input } from "antd";
|
import { App, Button, Form, Input, Segmented, Space } from "antd";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { fetchCurrentUser } from "@/services/api/auth";
|
||||||
|
import { useConfigStore } from "@/stores/use-config-store";
|
||||||
import { useUserStore } from "@/stores/use-user-store";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
type LoginFormValues = {
|
type LoginFormValues = {
|
||||||
@@ -26,13 +28,44 @@ function LoginContent() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const login = useUserStore((state) => state.login);
|
const login = useUserStore((state) => state.login);
|
||||||
|
const register = useUserStore((state) => state.register);
|
||||||
|
const setSession = useUserStore((state) => state.setSession);
|
||||||
const isLoading = useUserStore((state) => state.isLoading);
|
const isLoading = useUserStore((state) => state.isLoading);
|
||||||
|
const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true);
|
||||||
|
const allowRegister = useConfigStore((state) => state.publicSettings?.auth?.allowRegister !== false);
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const redirect = searchParams.get("redirect") || "/";
|
const redirect = searchParams.get("redirect") || "/";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
if (error) message.error(error);
|
||||||
|
if (!token) return;
|
||||||
|
void fetchCurrentUser(token).then((user) => {
|
||||||
|
setSession(token, user);
|
||||||
|
message.success("登录成功");
|
||||||
|
router.replace(redirect.startsWith("/") ? redirect : "/");
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}, [message, redirect, router, searchParams, setSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowRegister && mode === "register") setMode("login");
|
||||||
|
}, [allowRegister, mode]);
|
||||||
|
|
||||||
const submit = async (values: LoginFormValues) => {
|
const submit = async (values: LoginFormValues) => {
|
||||||
try {
|
try {
|
||||||
const user = await login({ username: values.username, password: values.password });
|
if (mode === "register" && !allowRegister) {
|
||||||
message.success("登录成功");
|
message.error("当前未开放注册");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "register" && values.password !== values.confirmPassword) {
|
||||||
|
message.error("两次输入的密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = mode === "register" ? register : login;
|
||||||
|
const user = await action({ username: values.username, password: values.password });
|
||||||
|
message.success(mode === "register" ? "注册成功" : "登录成功");
|
||||||
router.replace(redirect.startsWith("/") ? redirect : "/");
|
router.replace(redirect.startsWith("/") ? redirect : "/");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
if (user.role !== "admin") router.replace("/");
|
if (user.role !== "admin") router.replace("/");
|
||||||
@@ -53,20 +86,40 @@ function LoginContent() {
|
|||||||
}}
|
}}
|
||||||
aria-label="无限画布"
|
aria-label="无限画布"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">管理员登录</h1>
|
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">账号登录</h1>
|
||||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">当前暂时关闭注册,仅允许管理员账号登录。</p>
|
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">支持账号密码和 Linux.do 登录。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
|
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
|
||||||
|
<Form.Item>
|
||||||
|
<Segmented
|
||||||
|
block
|
||||||
|
value={mode}
|
||||||
|
onChange={(value) => setMode(value as "login" | "register")}
|
||||||
|
options={allowRegister ? [{ label: "登录", value: "login" }, { label: "注册", value: "register" }] : [{ label: "登录", value: "login" }]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200">用户名</span>} rules={[{ required: true, message: "请输入用户名" }]}>
|
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200">用户名</span>} rules={[{ required: true, message: "请输入用户名" }]}>
|
||||||
<Input prefix={<UserOutlined />} autoComplete="username" />
|
<Input prefix={<UserOutlined />} autoComplete="username" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200">密码</span>} rules={[{ required: true, message: "请输入密码" }]}>
|
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200">密码</span>} rules={[{ required: true, message: "请输入密码" }]}>
|
||||||
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
|
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{mode === "register" ? (
|
||||||
|
<Form.Item name="confirmPassword" label={<span className="font-medium text-stone-800 dark:text-stone-200">确认密码</span>} rules={[{ required: true, message: "请再次输入密码" }]}>
|
||||||
|
<Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
<Space orientation="vertical" size={12} style={{ width: "100%" }}>
|
||||||
<Button block type="primary" htmlType="submit" loading={isLoading}>
|
<Button block type="primary" htmlType="submit" loading={isLoading}>
|
||||||
登录
|
{mode === "register" ? "注册" : "登录"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{linuxDoEnabled ? (
|
||||||
|
<Button block href={`/api/auth/linux-do/authorize?redirect=${encodeURIComponent(redirect)}`} icon={<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />}>
|
||||||
|
使用 Linux.do 登录
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -8,15 +8,7 @@ import { fetchPrompts, type Prompt } from "@/services/api/prompts";
|
|||||||
import { navigationTools } from "@/constant/navigation-tools";
|
import { navigationTools } from "@/constant/navigation-tools";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Highlighter({
|
function Highlighter({ action, color, children }: { action: "highlight" | "underline"; color: string; children: ReactNode }) {
|
||||||
action,
|
|
||||||
color,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
action: "highlight" | "underline";
|
|
||||||
color: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-block px-1">
|
<span className="relative inline-block px-1">
|
||||||
{action === "highlight" ? (
|
{action === "highlight" ? (
|
||||||
@@ -37,7 +29,9 @@ export default function IndexPage() {
|
|||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchPrompts({ pageSize: 12 }).then((data) => setPromptShowcase(data.items)).catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
void fetchPrompts({ pageSize: 12 })
|
||||||
|
.then((data) => setPromptShowcase(data.items))
|
||||||
|
.catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,11 +41,17 @@ export default function IndexPage() {
|
|||||||
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||||
|
|
||||||
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
|
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
|
||||||
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">
|
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">无限画布</h1>
|
||||||
无限画布
|
|
||||||
</h1>
|
|
||||||
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
|
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
|
||||||
在<Highlighter action="underline" color="#FF9800">无限画布</Highlighter>中生成、连接和重组<Highlighter action="highlight" color="#87CEFA">图片、文字与图形</Highlighter>,让创作从单次生成变成连续推演。
|
在
|
||||||
|
<Highlighter action="underline" color="#FF9800">
|
||||||
|
无限画布
|
||||||
|
</Highlighter>
|
||||||
|
中生成、连接和重组
|
||||||
|
<Highlighter action="highlight" color="#87CEFA">
|
||||||
|
图片、文字与图形
|
||||||
|
</Highlighter>
|
||||||
|
,让创作从单次生成变成连续推演。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||||
<Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
<Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||||
@@ -68,9 +68,7 @@ export default function IndexPage() {
|
|||||||
<div />
|
<div />
|
||||||
<div className="max-w-2xl text-center">
|
<div className="max-w-2xl text-center">
|
||||||
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">沉淀每一次好结果</h2>
|
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">沉淀每一次好结果</h2>
|
||||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">
|
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。</p>
|
||||||
收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||||
查看提示词库
|
查看提示词库
|
||||||
@@ -117,7 +115,9 @@ export default function IndexPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)}
|
{promptShowcase.map((item) => (
|
||||||
|
<Image key={item.id} src={item.coverUrl} alt={item.title} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function PromptsPage() {
|
|||||||
|
|
||||||
const toggleTag = (tag: string) => {
|
const toggleTag = (tag: string) => {
|
||||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePromptAsset = (item: Prompt) => {
|
const savePromptAsset = (item: Prompt) => {
|
||||||
@@ -47,13 +47,20 @@ export default function PromptsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
|
<main
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
|
||||||
|
onScroll={handleListScroll}
|
||||||
|
>
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="mx-auto max-w-5xl text-center">
|
<div className="mx-auto max-w-5xl text-center">
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
||||||
</div>
|
</div>
|
||||||
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
{query.isLoading ? (
|
||||||
|
<div className="flex h-60 items-center justify-center">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{!query.isLoading ? (
|
{!query.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||||
@@ -74,7 +81,12 @@ export default function PromptsPage() {
|
|||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{promptTags.map((tag) => (
|
{promptTags.map((tag) => (
|
||||||
<Tag.CheckableTag key={tag} checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)} className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")} onChange={() => toggleTag(tag)}>
|
<Tag.CheckableTag
|
||||||
|
key={tag}
|
||||||
|
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
|
||||||
|
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
|
||||||
|
onChange={() => toggleTag(tag)}
|
||||||
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</Tag.CheckableTag>
|
</Tag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
@@ -89,11 +101,23 @@ export default function PromptsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{promptItems.map((item) => (
|
{promptItems.map((item) => (
|
||||||
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => copyText(item.prompt, "提示词已复制")} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>加入我的素材</Button>} />
|
<PromptCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onOpen={() => setSelectedPrompt(item)}
|
||||||
|
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||||
|
extraAction={
|
||||||
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||||
|
加入我的素材
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
|
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
|
||||||
|
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ function proxyHeaders(request: NextRequest) {
|
|||||||
headers.delete("host");
|
headers.delete("host");
|
||||||
headers.delete("content-length");
|
headers.delete("content-length");
|
||||||
headers.delete("connection");
|
headers.delete("connection");
|
||||||
|
headers.set("x-forwarded-host", request.nextUrl.host);
|
||||||
|
headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", ""));
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ async function proxy(request: NextRequest, context: RouteContext) {
|
|||||||
headers: proxyHeaders(request),
|
headers: proxyHeaders(request),
|
||||||
body: hasBody ? request.body : undefined,
|
body: hasBody ? request.body : undefined,
|
||||||
duplex: hasBody ? "half" : undefined,
|
duplex: hasBody ? "half" : undefined,
|
||||||
|
redirect: "manual",
|
||||||
} as RequestInit & { duplex?: "half" });
|
} as RequestInit & { duplex?: "half" });
|
||||||
|
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
|
|||||||
@@ -141,8 +141,7 @@
|
|||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.ai-title-aurora {
|
.ai-title-aurora {
|
||||||
background:
|
background: linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
|
||||||
linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
|
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -151,15 +150,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .ai-title-aurora {
|
.dark .ai-title-aurora {
|
||||||
background:
|
background: linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
|
||||||
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
|
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ai-title-aurora {
|
@keyframes ai-title-aurora {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
|
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
|
||||||
}
|
}
|
||||||
@@ -186,7 +185,11 @@
|
|||||||
color: #57534e;
|
color: #57534e;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-filter-tag.ant-tag-checkable:hover {
|
.prompt-filter-tag.ant-tag-checkable:hover {
|
||||||
@@ -215,7 +218,9 @@
|
|||||||
.dark .prompt-filter-tag.ant-tag-checkable-checked {
|
.dark .prompt-filter-tag.ant-tag-checkable-checked {
|
||||||
background: rgba(250, 250, 249, 0.96);
|
background: rgba(250, 250, 249, 0.96);
|
||||||
border-color: rgba(255, 255, 255, 0.88);
|
border-color: rgba(255, 255, 255, 0.88);
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 18px rgba(0, 0, 0, 0.22);
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.18),
|
||||||
|
0 6px 18px rgba(0, 0, 0, 0.22);
|
||||||
color: #0c0a09 !important;
|
color: #0c0a09 !important;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -314,6 +319,71 @@
|
|||||||
line-height: 1 !important;
|
line-height: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-composer-icon.ant-btn {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-mode-button {
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-composer-mode-button span,
|
||||||
|
.canvas-composer-tools .canvas-model-picker-text,
|
||||||
|
.canvas-composer-tools .canvas-select-chevron,
|
||||||
|
.canvas-composer-tools .canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-composer-model-picker {
|
||||||
|
width: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 440px) {
|
||||||
|
.canvas-composer-mode-button {
|
||||||
|
padding-inline: 8px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-composer-mode-button span {
|
||||||
|
display: inline;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-icon.ant-btn {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 170px;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-composer-model-picker {
|
||||||
|
width: auto;
|
||||||
|
min-width: 9rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools .canvas-model-picker-text,
|
||||||
|
.canvas-composer-tools .canvas-select-chevron {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-control-select.ant-select-focused .ant-select-selector,
|
.canvas-control-select.ant-select-focused .ant-select-selector,
|
||||||
.canvas-control-number.ant-input-number-focused {
|
.canvas-control-number.ant-input-number-focused {
|
||||||
border-color: #a8a29e !important;
|
border-color: #a8a29e !important;
|
||||||
@@ -329,14 +399,6 @@
|
|||||||
border-color: #78716c !important;
|
border-color: #78716c !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
height: 30px;
|
|
||||||
justify-content: center;
|
|
||||||
padding-inline: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-image-settings-popover .ant-popover-inner {
|
.canvas-image-settings-popover .ant-popover-inner {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
|
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
|
||||||
@@ -396,10 +458,10 @@
|
|||||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(root), ::view-transition-new(root) {
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
animation: none;
|
animation: none;
|
||||||
mix-blend-mode: normal;
|
mix-blend-mode: normal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className="bg-background text-foreground antialiased"
|
className="bg-background text-foreground antialiased"
|
||||||
style={{
|
style={{
|
||||||
fontFamily:
|
fontFamily: '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||||
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Script
|
<Script
|
||||||
|
|||||||
@@ -6,19 +6,18 @@ export default function NotFound() {
|
|||||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||||
<section className="w-full max-w-md text-center">
|
<section className="w-full max-w-md text-center">
|
||||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">404</div>
|
||||||
404
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">
|
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">这个地址没有对应的页面,可能已经移动或被合并到其他入口。</p>
|
||||||
这个地址没有对应的页面,可能已经移动或被合并到其他入口。
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||||
<Link href="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
<Link href="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||||
<Home className="size-4" />
|
<Home className="size-4" />
|
||||||
返回首页
|
返回首页
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login" className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800">
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800"
|
||||||
|
>
|
||||||
<LogIn className="size-4" />
|
<LogIn className="size-4" />
|
||||||
去登录
|
去登录
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function AppConfigModal() {
|
|||||||
const finishConfig = () => {
|
const finishConfig = () => {
|
||||||
setConfigDialogOpen(false);
|
setConfigDialogOpen(false);
|
||||||
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
|
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
|
||||||
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
|
if (!modelConfig.imageModel.trim() || !modelConfig.videoModel.trim() || !modelConfig.textModel.trim()) return;
|
||||||
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
|
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
|
||||||
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
|
||||||
clearPromptContinue();
|
clearPromptContinue();
|
||||||
@@ -43,6 +43,7 @@ export function AppConfigModal() {
|
|||||||
const models = await fetchImageModels(config);
|
const models = await fetchImageModels(config);
|
||||||
updateConfig("models", models);
|
updateConfig("models", models);
|
||||||
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
|
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
|
||||||
|
if (models.length && !models.includes(config.videoModel)) updateConfig("videoModel", models[0]);
|
||||||
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
|
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
|
||||||
message.success("模型列表已更新");
|
message.success("模型列表已更新");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -54,12 +55,21 @@ export function AppConfigModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={<div><div className="text-lg font-semibold">配置</div><div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div></div>}
|
title={
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">配置</div>
|
||||||
|
<div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
open={isConfigOpen}
|
open={isConfigOpen}
|
||||||
width={760}
|
width={760}
|
||||||
centered
|
centered
|
||||||
onCancel={() => setConfigDialogOpen(false)}
|
onCancel={() => setConfigDialogOpen(false)}
|
||||||
footer={<Button type="primary" onClick={finishConfig}>完成</Button>}
|
footer={
|
||||||
|
<Button type="primary" onClick={finishConfig}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
@@ -70,7 +80,10 @@ export function AppConfigModal() {
|
|||||||
size="middle"
|
size="middle"
|
||||||
value={effectiveMode}
|
value={effectiveMode}
|
||||||
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
|
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
|
||||||
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
|
options={[
|
||||||
|
{ label: "本地直连", value: "local" },
|
||||||
|
{ label: "云端渠道", value: "remote" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -89,7 +102,9 @@ export function AppConfigModal() {
|
|||||||
<div className="text-sm font-medium">模型列表</div>
|
<div className="text-sm font-medium">模型列表</div>
|
||||||
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
|
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>拉取模型列表</Button>
|
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
|
||||||
|
拉取模型列表
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -98,10 +113,13 @@ export function AppConfigModal() {
|
|||||||
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Form.Item label="默认生图模型" className="mb-4">
|
<Form.Item label="默认生图模型" className="mb-4">
|
||||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="默认视频模型" className="mb-4">
|
||||||
|
<ModelPicker config={modelConfig} value={modelConfig.videoModel} onChange={(model) => updateConfig("videoModel", model)} fullWidth />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="默认文本模型" className="mb-4">
|
<Form.Item label="默认文本模型" className="mb-4">
|
||||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ export function AppTopNav() {
|
|||||||
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
|
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
|
||||||
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
|
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
|
||||||
<div className="flex min-w-0 items-center">
|
<div className="flex min-w-0 items-center">
|
||||||
<Link
|
<Link href="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300">
|
||||||
href="/"
|
|
||||||
className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
className="size-5 shrink-0 bg-current"
|
className="size-5 shrink-0 bg-current"
|
||||||
style={{
|
style={{
|
||||||
@@ -117,7 +114,6 @@ export function AppTopNav() {
|
|||||||
|
|
||||||
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
||||||
<AppConfigModal />
|
<AppConfigModal />
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
|
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
|
||||||
active
|
active ? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
|
||||||
? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100"
|
|
||||||
: "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" />
|
<Icon className="size-5" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, RefObject } from "react";
|
import type { CSSProperties, RefObject } from "react";
|
||||||
import { Dropdown } from "antd";
|
import { Avatar, Dropdown, Tooltip } from "antd";
|
||||||
import { Keyboard, LogOut, Settings2, Shield } from "lucide-react";
|
import { Keyboard, LogOut, Settings2, Shield } from "lucide-react";
|
||||||
import type { ItemType } from "antd/es/menu/interface";
|
import type { ItemType } from "antd/es/menu/interface";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -9,6 +9,7 @@ import Link from "next/link";
|
|||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
import { GitHubLink } from "@/components/layout/github-link";
|
import { GitHubLink } from "@/components/layout/github-link";
|
||||||
import { VersionReleaseModal } from "@/components/layout/version-release-modal";
|
import { VersionReleaseModal } from "@/components/layout/version-release-modal";
|
||||||
|
import { CreditSymbol } from "@/constant/credits";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { useConfigStore } from "@/stores/use-config-store";
|
import { useConfigStore } from "@/stores/use-config-store";
|
||||||
@@ -25,29 +26,23 @@ type UserStatusActionsProps = {
|
|||||||
getPopupContainer?: (node: HTMLElement) => HTMLElement;
|
getPopupContainer?: (node: HTMLElement) => HTMLElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserStatusActions({
|
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) {
|
||||||
showConfig = true,
|
|
||||||
variant = "default",
|
|
||||||
onOpenShortcuts,
|
|
||||||
accountOpen,
|
|
||||||
onAccountOpenChange,
|
|
||||||
accountRef,
|
|
||||||
getPopupContainer,
|
|
||||||
}: UserStatusActionsProps) {
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
const user = useUserStore((state) => state.user);
|
const user = useUserStore((state) => state.user);
|
||||||
const logout = useUserStore((state) => state.clearSession);
|
const logout = useUserStore((state) => state.clearSession);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
const canvasTheme = canvasThemes[theme];
|
const canvasTheme = canvasThemes[theme];
|
||||||
const userName = user?.username || "用户";
|
const userName = user?.displayName || user?.username || "用户";
|
||||||
|
const credits = user?.credits ?? 0;
|
||||||
|
const avatarUrl = user?.avatarUrl?.trim();
|
||||||
const avatarText = (userName.trim()[0] || "U").toUpperCase();
|
const avatarText = (userName.trim()[0] || "U").toUpperCase();
|
||||||
const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
|
const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
|
||||||
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
|
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
|
||||||
const versionStyle = iconStyle;
|
const versionStyle = iconStyle;
|
||||||
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
|
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
|
||||||
const gitHubStyle = iconStyle;
|
const gitHubStyle = iconStyle;
|
||||||
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
|
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined;
|
||||||
const menuItems: ItemType[] = [
|
const menuItems: ItemType[] = [
|
||||||
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
|
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
|
||||||
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
||||||
@@ -59,44 +54,33 @@ export function UserStatusActions({
|
|||||||
return (
|
return (
|
||||||
<div className="inline-flex shrink-0 items-center gap-1.5">
|
<div className="inline-flex shrink-0 items-center gap-1.5">
|
||||||
{showConfig ? (
|
{showConfig ? (
|
||||||
<button
|
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
|
||||||
type="button"
|
|
||||||
className={naturalIconClass}
|
|
||||||
style={iconStyle}
|
|
||||||
onClick={() => openConfigDialog(false)}
|
|
||||||
aria-label="配置"
|
|
||||||
title="配置"
|
|
||||||
>
|
|
||||||
<Settings2 className="size-4" />
|
<Settings2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<AnimatedThemeToggler
|
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
|
||||||
theme={theme}
|
|
||||||
onThemeChange={setTheme}
|
|
||||||
className={naturalIconClass}
|
|
||||||
style={iconStyle}
|
|
||||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
|
||||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
|
||||||
/>
|
|
||||||
<VersionReleaseModal style={versionStyle} />
|
<VersionReleaseModal style={versionStyle} />
|
||||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||||
|
{variant === "canvas" ? (
|
||||||
|
<Tooltip title="当前算力点余额" placement="bottom">
|
||||||
|
<div className="flex h-8 shrink-0 items-center gap-1.5 px-1.5 text-xs font-medium tabular-nums opacity-75 transition hover:opacity-100" style={{ color: canvasTheme.node.text }}>
|
||||||
|
<CreditSymbol className="text-sm leading-none" />
|
||||||
|
<span>{credits.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<div ref={accountRef}>
|
<div ref={accountRef}>
|
||||||
<Dropdown
|
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
|
||||||
open={accountOpen}
|
<button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
|
||||||
onOpenChange={onAccountOpenChange}
|
<Avatar
|
||||||
trigger={["click"]}
|
size={28}
|
||||||
placement="bottomRight"
|
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
|
||||||
getPopupContainer={getPopupContainer}
|
alt={userName}
|
||||||
styles={{ root: { minWidth: 150 } }}
|
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-xs font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||||
menu={{ items: menuItems }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
|
||||||
style={avatarStyle}
|
style={avatarStyle}
|
||||||
aria-label="账户菜单"
|
|
||||||
>
|
>
|
||||||
<span className="leading-none">{avatarText}</span>
|
{avatarText}
|
||||||
|
</Avatar>
|
||||||
</button>
|
</button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,14 +39,7 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
|
|||||||
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Modal
|
<Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
|
||||||
title="版本更新"
|
|
||||||
open={open}
|
|
||||||
width={680}
|
|
||||||
centered
|
|
||||||
footer={null}
|
|
||||||
onCancel={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<div className="mb-5 grid grid-cols-2 gap-3">
|
<div className="mb-5 grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||||
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
|
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
|
||||||
@@ -82,7 +75,9 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
|
|||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-1.5">
|
||||||
{release.items.map((item, index) => (
|
{release.items.map((item, index) => (
|
||||||
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
|
||||||
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
|
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
|
||||||
|
{item.type}
|
||||||
|
</Tag>
|
||||||
<span className="min-w-0 flex-1">{item.content}</span>
|
<span className="min-w-0 flex-1">{item.content}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Select } from "antd";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { Cpu } from "lucide-react";
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { AiConfig } from "@/stores/use-config-store";
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
|
||||||
type ModelPickerProps = {
|
type ModelPickerProps = {
|
||||||
@@ -15,27 +18,94 @@ type ModelPickerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
|
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
|
||||||
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: model }));
|
const pickerId = useId();
|
||||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
const [open, setOpen] = useState(false);
|
||||||
|
const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter(Boolean))), [config.channelMode, config.models, value]);
|
||||||
|
const current = value || "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOtherPicker = (event: Event) => {
|
||||||
|
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("model-picker-open", closeOtherPicker);
|
||||||
|
return () => window.removeEventListener("model-picker-open", closeOtherPicker);
|
||||||
|
}, [pickerId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
open={open}
|
||||||
className={`canvas-control-select ${className || ""}`}
|
value={current}
|
||||||
popupMatchSelectWidth={false}
|
onOpenChange={(nextOpen) => {
|
||||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
if (nextOpen && !options.length && config.channelMode === "local") {
|
||||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
onMissingConfig?.();
|
||||||
value={value || undefined}
|
return;
|
||||||
placeholder={placeholder}
|
}
|
||||||
options={options}
|
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
|
||||||
notFoundContent="请先到配置里拉取模型列表"
|
setOpen(nextOpen);
|
||||||
onChange={onChange}
|
}}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"canvas-composer-model-picker h-8 w-fit max-w-full gap-2 rounded-full border border-input bg-transparent px-3 text-sm font-normal shadow-sm transition-colors",
|
||||||
|
fullWidth ? "w-full min-w-0 justify-start" : "min-w-[9rem] justify-start",
|
||||||
|
"data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={() => {
|
title={current || placeholder}
|
||||||
if (!options.length) onMissingConfig?.();
|
>
|
||||||
}}
|
<ModelIcon model={current} />
|
||||||
filterOption={(input, option) => String(option?.label || "").toLowerCase().includes(input.toLowerCase())}
|
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
|
||||||
/>
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
data-canvas-no-zoom
|
||||||
|
className="z-[1200] w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl"
|
||||||
|
position="popper"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={6}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{options.length ? (
|
||||||
|
options.map((model) => (
|
||||||
|
<SelectItem key={model} value={model} textValue={model}>
|
||||||
|
<ModelLabel model={model} />
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="__empty__" disabled>
|
||||||
|
{config.channelMode === "remote" ? "暂无可用模型" : "请先到配置里拉取模型列表"}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelLabel({ model }: { model: string }) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ModelIcon model={model} />
|
||||||
|
<span className="truncate">{model}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelIcon({ model }: { model: string }) {
|
||||||
|
const icon = resolveModelIcon(model);
|
||||||
|
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelIcon(model: string) {
|
||||||
|
const name = model.toLowerCase();
|
||||||
|
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||||
|
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||||
|
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||||
|
if (name.includes("grok") || name.includes("grok")) return "/icons/grok.svg";
|
||||||
|
if (name.includes("deepseek") || name.includes("deepseek")) return "/icons/deepseek.svg";
|
||||||
|
if (name.includes("glm") || name.includes("glm")) return "/icons/glm.svg";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,33 @@ import { Button, Card, Tag } from "antd";
|
|||||||
|
|
||||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||||
|
|
||||||
export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actionIcon = <Copy className="size-3.5" />, actionType = "text", extraAction }: { item: Prompt; onOpen: () => void; onCopy: () => void; actionLabel?: string; actionIcon?: ReactNode; actionType?: "text" | "primary"; extraAction?: ReactNode }) {
|
export function PromptCard({
|
||||||
|
item,
|
||||||
|
onOpen,
|
||||||
|
onCopy,
|
||||||
|
actionLabel = "复制",
|
||||||
|
actionIcon = <Copy className="size-3.5" />,
|
||||||
|
actionType = "text",
|
||||||
|
extraAction,
|
||||||
|
}: {
|
||||||
|
item: Prompt;
|
||||||
|
onOpen: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionIcon?: ReactNode;
|
||||||
|
actionType?: "text" | "primary";
|
||||||
|
extraAction?: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
cover={<button type="button" className="block w-full text-left" onClick={onOpen}><img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /></button>}
|
cover={
|
||||||
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
|
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -22,12 +42,18 @@ export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actio
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
|
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
{item.tags.map((tag) => (
|
||||||
|
<Tag key={tag} className="m-0 text-[11px]">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 px-4 pb-4">
|
<div className="flex items-center gap-2 px-4 pb-4">
|
||||||
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel}</Button>
|
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
{extraAction}
|
{extraAction}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,12 +17,26 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
|||||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap gap-1.5">{prompt.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{prompt.tags.map((tag) => (
|
||||||
|
<Tag key={tag} className="m-0">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}</div>
|
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
|
||||||
|
创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}
|
||||||
|
</div>
|
||||||
<Space wrap className="mt-5">
|
<Space wrap className="mt-5">
|
||||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>复制提示词</Button>
|
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||||
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>加入我的素材</Button> : null}
|
复制提示词
|
||||||
|
</Button>
|
||||||
|
{onSaveAsset ? (
|
||||||
|
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||||
|
加入我的素材
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
|||||||
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
|
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
|
||||||
const toggleTag = (tag: string) => {
|
const toggleTag = (tag: string) => {
|
||||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||||
};
|
};
|
||||||
const selectPrompt = (prompt: string) => {
|
const selectPrompt = (prompt: string) => {
|
||||||
onSelect(prompt);
|
onSelect(prompt);
|
||||||
@@ -65,14 +65,22 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
|
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
|
||||||
{query.isLoading ? <div className="flex h-40 items-center justify-center"><Spin /></div> : null}
|
{query.isLoading ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
|
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
|
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
|
||||||
{query.isFetchingNextPage ? <div className="py-4 text-center"><Spin size="small" /></div> : null}
|
{query.isFetchingNextPage ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function usePromptList({ keyword, tags, category, enabled = true }: { key
|
|||||||
queryKey: ["prompts", keyword, tags, category],
|
queryKey: ["prompts", keyword, tags, category],
|
||||||
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
|
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined,
|
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
const firstPage = query.data?.pages[0];
|
const firstPage = query.data?.pages[0];
|
||||||
|
|||||||
@@ -1,241 +1,172 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { flushSync } from "react-dom"
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TransitionVariant =
|
export type TransitionVariant = "circle" | "square" | "triangle" | "diamond" | "hexagon" | "rectangle" | "star";
|
||||||
| "circle"
|
|
||||||
| "square"
|
|
||||||
| "triangle"
|
|
||||||
| "diamond"
|
|
||||||
| "hexagon"
|
|
||||||
| "rectangle"
|
|
||||||
| "star"
|
|
||||||
|
|
||||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||||
duration?: number
|
duration?: number;
|
||||||
variant?: TransitionVariant
|
variant?: TransitionVariant;
|
||||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
/** When true, the transition expands from the viewport center instead of the button center. */
|
||||||
fromCenter?: boolean
|
fromCenter?: boolean;
|
||||||
theme?: "light" | "dark"
|
theme?: "light" | "dark";
|
||||||
targetTheme?: "light" | "dark"
|
targetTheme?: "light" | "dark";
|
||||||
onThemeChange?: (theme: "light" | "dark") => void
|
onThemeChange?: (theme: "light" | "dark") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||||
const pairs = Array.from(
|
const pairs = Array.from({ length: vertexCount }, () => `${cx}px ${cy}px`).join(", ");
|
||||||
{ length: vertexCount },
|
return `polygon(${pairs})`;
|
||||||
() => `${cx}px ${cy}px`
|
|
||||||
).join(", ")
|
|
||||||
return `polygon(${pairs})`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThemeTransitionClipPaths(
|
function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy: number, maxRadius: number, viewportWidth: number, viewportHeight: number): [string, string] {
|
||||||
variant: TransitionVariant,
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
maxRadius: number,
|
|
||||||
viewportWidth: number,
|
|
||||||
viewportHeight: number
|
|
||||||
): [string, string] {
|
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case "circle":
|
case "circle":
|
||||||
return [
|
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
||||||
`circle(0px at ${cx}px ${cy}px)`,
|
|
||||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
|
||||||
]
|
|
||||||
case "square": {
|
case "square": {
|
||||||
const halfW = Math.max(cx, viewportWidth - cx)
|
const halfW = Math.max(cx, viewportWidth - cx);
|
||||||
const halfH = Math.max(cy, viewportHeight - cy)
|
const halfH = Math.max(cy, viewportHeight - cy);
|
||||||
const halfSide = Math.max(halfW, halfH) * 1.05
|
const halfSide = Math.max(halfW, halfH) * 1.05;
|
||||||
const end = [
|
const end = [`${cx - halfSide}px ${cy - halfSide}px`, `${cx + halfSide}px ${cy - halfSide}px`, `${cx + halfSide}px ${cy + halfSide}px`, `${cx - halfSide}px ${cy + halfSide}px`].join(", ");
|
||||||
`${cx - halfSide}px ${cy - halfSide}px`,
|
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||||
`${cx + halfSide}px ${cy - halfSide}px`,
|
|
||||||
`${cx + halfSide}px ${cy + halfSide}px`,
|
|
||||||
`${cx - halfSide}px ${cy + halfSide}px`,
|
|
||||||
].join(", ")
|
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
|
||||||
}
|
}
|
||||||
case "triangle": {
|
case "triangle": {
|
||||||
const scale = maxRadius * 2.2
|
const scale = maxRadius * 2.2;
|
||||||
const dx = (Math.sqrt(3) / 2) * scale
|
const dx = (Math.sqrt(3) / 2) * scale;
|
||||||
const verts = [
|
const verts = [`${cx}px ${cy - scale}px`, `${cx + dx}px ${cy + 0.5 * scale}px`, `${cx - dx}px ${cy + 0.5 * scale}px`].join(", ");
|
||||||
`${cx}px ${cy - scale}px`,
|
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`];
|
||||||
`${cx + dx}px ${cy + 0.5 * scale}px`,
|
|
||||||
`${cx - dx}px ${cy + 0.5 * scale}px`,
|
|
||||||
].join(", ")
|
|
||||||
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
|
|
||||||
}
|
}
|
||||||
case "diamond": {
|
case "diamond": {
|
||||||
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
||||||
const R = maxRadius * Math.SQRT2
|
const R = maxRadius * Math.SQRT2;
|
||||||
const end = [
|
const end = [`${cx}px ${cy - R}px`, `${cx + R}px ${cy}px`, `${cx}px ${cy + R}px`, `${cx - R}px ${cy}px`].join(", ");
|
||||||
`${cx}px ${cy - R}px`,
|
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||||
`${cx + R}px ${cy}px`,
|
|
||||||
`${cx}px ${cy + R}px`,
|
|
||||||
`${cx - R}px ${cy}px`,
|
|
||||||
].join(", ")
|
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
|
||||||
}
|
}
|
||||||
case "hexagon": {
|
case "hexagon": {
|
||||||
const R = maxRadius * Math.SQRT2
|
const R = maxRadius * Math.SQRT2;
|
||||||
const verts: string[] = []
|
const verts: string[] = [];
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const a = -Math.PI / 2 + (i * Math.PI) / 3
|
const a = -Math.PI / 2 + (i * Math.PI) / 3;
|
||||||
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
|
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`);
|
||||||
}
|
}
|
||||||
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
|
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`];
|
||||||
}
|
}
|
||||||
case "rectangle": {
|
case "rectangle": {
|
||||||
const halfW = Math.max(cx, viewportWidth - cx)
|
const halfW = Math.max(cx, viewportWidth - cx);
|
||||||
const halfH = Math.max(cy, viewportHeight - cy)
|
const halfH = Math.max(cy, viewportHeight - cy);
|
||||||
const end = [
|
const end = [`${cx - halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy + halfH}px`, `${cx - halfW}px ${cy + halfH}px`].join(", ");
|
||||||
`${cx - halfW}px ${cy - halfH}px`,
|
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||||
`${cx + halfW}px ${cy - halfH}px`,
|
|
||||||
`${cx + halfW}px ${cy + halfH}px`,
|
|
||||||
`${cx - halfW}px ${cy + halfH}px`,
|
|
||||||
].join(", ")
|
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
|
||||||
}
|
}
|
||||||
case "star": {
|
case "star": {
|
||||||
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
||||||
const R = maxRadius * Math.SQRT2 * 1.03
|
const R = maxRadius * Math.SQRT2 * 1.03;
|
||||||
const innerRatio = 0.42
|
const innerRatio = 0.42;
|
||||||
const starPolygon = (radius: number) => {
|
const starPolygon = (radius: number) => {
|
||||||
const verts: string[] = []
|
const verts: string[] = [];
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
|
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
||||||
verts.push(
|
verts.push(`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`);
|
||||||
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
|
const innerA = outerA + Math.PI / 5;
|
||||||
)
|
verts.push(`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`);
|
||||||
const innerA = outerA + Math.PI / 5
|
|
||||||
verts.push(
|
|
||||||
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return `polygon(${verts.join(", ")})`
|
return `polygon(${verts.join(", ")})`;
|
||||||
}
|
};
|
||||||
const startR = Math.max(2, R * 0.025)
|
const startR = Math.max(2, R * 0.025);
|
||||||
return [starPolygon(startR), starPolygon(R)]
|
return [starPolygon(startR), starPolygon(R)];
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return [
|
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
||||||
`circle(0px at ${cx}px ${cy}px)`,
|
|
||||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedThemeToggler = ({
|
export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => {
|
||||||
children,
|
const shape = variant ?? "circle";
|
||||||
className,
|
const [isDark, setIsDark] = useState(false);
|
||||||
duration = 400,
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
variant,
|
|
||||||
fromCenter = false,
|
|
||||||
theme,
|
|
||||||
targetTheme,
|
|
||||||
onThemeChange,
|
|
||||||
...props
|
|
||||||
}: AnimatedThemeTogglerProps) => {
|
|
||||||
const shape = variant ?? "circle"
|
|
||||||
const [isDark, setIsDark] = useState(false)
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
setIsDark(theme === "dark")
|
setIsDark(theme === "dark");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTheme = () => {
|
const updateTheme = () => {
|
||||||
setIsDark(document.documentElement.classList.contains("dark"))
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
}
|
};
|
||||||
|
|
||||||
updateTheme()
|
updateTheme();
|
||||||
|
|
||||||
const observer = new MutationObserver(updateTheme)
|
const observer = new MutationObserver(updateTheme);
|
||||||
observer.observe(document.documentElement, {
|
observer.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ["class"],
|
attributeFilter: ["class"],
|
||||||
})
|
});
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect();
|
||||||
}, [theme])
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
const button = buttonRef.current
|
const button = buttonRef.current;
|
||||||
if (!button) return
|
if (!button) return;
|
||||||
|
|
||||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
|
||||||
let x: number
|
let x: number;
|
||||||
let y: number
|
let y: number;
|
||||||
if (fromCenter) {
|
if (fromCenter) {
|
||||||
x = viewportWidth / 2
|
x = viewportWidth / 2;
|
||||||
y = viewportHeight / 2
|
y = viewportHeight / 2;
|
||||||
} else {
|
} else {
|
||||||
const { top, left, width, height } = button.getBoundingClientRect()
|
const { top, left, width, height } = button.getBoundingClientRect();
|
||||||
x = left + width / 2
|
x = left + width / 2;
|
||||||
y = top + height / 2
|
y = top + height / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxRadius = Math.hypot(
|
const maxRadius = Math.hypot(Math.max(x, viewportWidth - x), Math.max(y, viewportHeight - y));
|
||||||
Math.max(x, viewportWidth - x),
|
|
||||||
Math.max(y, viewportHeight - y)
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyTheme = () => {
|
const applyTheme = () => {
|
||||||
const nextTheme = targetTheme ?? (isDark ? "light" : "dark")
|
const nextTheme = targetTheme ?? (isDark ? "light" : "dark");
|
||||||
if (nextTheme === (isDark ? "dark" : "light")) return
|
if (nextTheme === (isDark ? "dark" : "light")) return;
|
||||||
setIsDark(nextTheme === "dark")
|
setIsDark(nextTheme === "dark");
|
||||||
document.documentElement.classList.toggle("dark", nextTheme === "dark")
|
document.documentElement.classList.toggle("dark", nextTheme === "dark");
|
||||||
document.documentElement.style.colorScheme = nextTheme
|
document.documentElement.style.colorScheme = nextTheme;
|
||||||
onThemeChange?.(nextTheme)
|
onThemeChange?.(nextTheme);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (typeof document.startViewTransition !== "function") {
|
if (typeof document.startViewTransition !== "function") {
|
||||||
applyTheme()
|
applyTheme();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipPath = getThemeTransitionClipPaths(
|
const clipPath = getThemeTransitionClipPaths(shape, x, y, maxRadius, viewportWidth, viewportHeight);
|
||||||
shape,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
maxRadius,
|
|
||||||
viewportWidth,
|
|
||||||
viewportHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
const root = document.documentElement
|
const root = document.documentElement;
|
||||||
root.dataset.magicuiThemeVt = "active"
|
root.dataset.magicuiThemeVt = "active";
|
||||||
root.style.setProperty(
|
root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
|
||||||
"--magicui-theme-toggle-vt-duration",
|
|
||||||
`${duration}ms`
|
|
||||||
)
|
|
||||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
||||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
// theme unclipped between snapshot and the ready.then() JS animation.
|
||||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
|
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
delete root.dataset.magicuiThemeVt
|
delete root.dataset.magicuiThemeVt;
|
||||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
|
root.style.removeProperty("--magicui-theme-toggle-vt-duration");
|
||||||
root.style.removeProperty("--magicui-theme-vt-clip-from")
|
root.style.removeProperty("--magicui-theme-vt-clip-from");
|
||||||
}
|
};
|
||||||
|
|
||||||
const transition = document.startViewTransition(() => {
|
const transition = document.startViewTransition(() => {
|
||||||
flushSync(applyTheme)
|
flushSync(applyTheme);
|
||||||
})
|
});
|
||||||
if (typeof transition?.finished?.finally === "function") {
|
if (typeof transition?.finished?.finally === "function") {
|
||||||
transition.finished.finally(cleanup)
|
transition.finished.finally(cleanup);
|
||||||
} else {
|
} else {
|
||||||
cleanup()
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = transition?.ready
|
const ready = transition?.ready;
|
||||||
if (ready && typeof ready.then === "function") {
|
if (ready && typeof ready.then === "function") {
|
||||||
ready.then(() => {
|
ready.then(() => {
|
||||||
document.documentElement.animate(
|
document.documentElement.animate(
|
||||||
@@ -248,22 +179,16 @@ export const AnimatedThemeToggler = ({
|
|||||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
easing: shape === "star" ? "linear" : "ease-in-out",
|
||||||
fill: "forwards",
|
fill: "forwards",
|
||||||
pseudoElement: "::view-transition-new(root)",
|
pseudoElement: "::view-transition-new(root)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange]);
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
|
||||||
type="button"
|
|
||||||
ref={buttonRef}
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (isDark ? <Sun /> : <Moon />)}
|
{children ?? (isDark ? <Sun /> : <Moon />)}
|
||||||
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||