mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
perf: 优化 CI 构建时间 v0.57.0
- whisper-rs 改为可选依赖 (local-whisper feature) - 禁用 LTO (CARGO_PROFILE_RELEASE_LTO=off) - 增加 codegen-units 到 32 - 移除构建后清理步骤 - 统一内容创作系统增强 Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
@@ -116,8 +116,10 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_PROFILE_RELEASE_LTO: thin
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
|
||||
# 禁用 LTO 加速编译(正式发布可改为 thin)
|
||||
CARGO_PROFILE_RELEASE_LTO: "off"
|
||||
# 增加并行编译单元
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
|
||||
CARGO_INCREMENTAL: 0
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -147,6 +149,7 @@ jobs:
|
||||
- **API Key**: 首次启动自动生成,可在设置页查看/修改
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
- name: Build Tauri app (macOS/Windows)
|
||||
@@ -154,8 +157,10 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_PROFILE_RELEASE_LTO: thin
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 16
|
||||
# 禁用 LTO 加速编译(正式发布可改为 thin)
|
||||
CARGO_PROFILE_RELEASE_LTO: "off"
|
||||
# 增加并行编译单元
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
|
||||
CARGO_INCREMENTAL: 0
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -192,29 +197,12 @@ jobs:
|
||||
- **API Key**: 首次启动自动生成,可在设置页查看/修改
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
# 构建后清理,节省缓存空间
|
||||
- name: Post-build cleanup (Unix)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
run: |
|
||||
rm -rf src-tauri/target/release/deps || true
|
||||
rm -rf src-tauri/target/release/build || true
|
||||
rm -rf src-tauri/target/release/incremental || true
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/deps || true
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/build || true
|
||||
rm -rf src-tauri/target/${{ matrix.target }}/release/incremental || true
|
||||
|
||||
- name: Post-build cleanup (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/deps
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/build
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/release/incremental
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/deps
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/build
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue src-tauri/target/${{ matrix.target }}/release/incremental
|
||||
# 注意:移除了 Post-build cleanup 步骤
|
||||
# 之前的清理会删除 deps/build/incremental 目录,导致缓存无法复用
|
||||
# 保留这些文件可以让 rust-cache 更好地工作
|
||||
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 图文海报功能实现计划
|
||||
|
||||
## 当前进度
|
||||
|
||||
### Phase 1: 品牌人设系统扩展 ✅ 完成
|
||||
**Goal**: 扩展现有人设系统,支持海报设计专用字段(配色、字体、品牌调性)
|
||||
**Status**: Complete
|
||||
|
||||
#### 已完成任务
|
||||
- [x] 1.1 扩展数据模型 (`project_model.rs`)
|
||||
- 新增 BrandPersonality, DesignStyle 枚举
|
||||
- 新增 ColorScheme, Typography, LogoPlacement, ImageStyle, IconStyle 结构体
|
||||
- 新增 BrandTone, DesignConfig, VisualConfig 结构体
|
||||
- 新增 BrandPersonaExtension, BrandPersona, BrandPersonaTemplate 结构体
|
||||
- 新增相关请求类型
|
||||
|
||||
- [x] 1.2 扩展数据库 Schema (`schema.rs`)
|
||||
- 新增 `brand_persona_extensions` 表
|
||||
- 包含 persona_id, brand_tone_json, design_json, visual_json 字段
|
||||
|
||||
- [x] 1.3 创建 BrandPersona DAO (`brand_persona_dao.rs`)
|
||||
- 实现 create, get, update, delete 方法
|
||||
- 实现 get_brand_persona 获取完整品牌人设
|
||||
- 实现 list_templates 获取预设模板
|
||||
|
||||
- [x] 1.4 扩展 PersonaService (`persona_service.rs`)
|
||||
- 新增 get_brand_persona, get_brand_extension 方法
|
||||
- 新增 save_brand_extension, update_brand_extension 方法
|
||||
- 新增 delete_brand_extension, list_brand_persona_templates 方法
|
||||
|
||||
- [x] 1.5 扩展 Tauri 命令 (`persona_cmd.rs`)
|
||||
- 新增 get_brand_persona, get_brand_extension 命令
|
||||
- 新增 save_brand_extension, update_brand_extension 命令
|
||||
- 新增 delete_brand_extension, list_brand_persona_templates 命令
|
||||
- 在 runner.rs 中注册新命令
|
||||
|
||||
- [x] 1.6 新增前端类型 (`brand-persona.ts`)
|
||||
- 定义所有品牌人设相关的 TypeScript 类型
|
||||
- 包含预设配色方案、字体列表、默认值等常量
|
||||
|
||||
- [x] 1.7 新增 useBrandPersona Hook (`useBrandPersona.ts`)
|
||||
- 实现品牌人设的 CRUD 操作
|
||||
- 支持模板应用功能
|
||||
|
||||
- [x] 1.8 新增 BrandPersonaDialog 组件 (`BrandPersonaDialog.tsx`)
|
||||
- 分步骤创建品牌人设(品牌调性 → 配色方案 → 字体设置 → 预览确认)
|
||||
- 支持模板快速应用
|
||||
- 支持预设配色方案选择
|
||||
- 实时预览效果
|
||||
|
||||
#### 验证标准
|
||||
- [x] 能够创建包含配色方案的品牌人设
|
||||
- [x] 品牌人设能够正确保存和加载
|
||||
- [x] 在项目详情页能够管理品牌人设
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 素材库扩展
|
||||
**Goal**: 扩展素材库支持 icon, color, layout 类型
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 海报 Agent 系统
|
||||
**Goal**: 实现 6 个专用 Agent,支持对话式海报设计
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 工作流系统
|
||||
**Goal**: 实现 6 步引导工作流
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 多平台导出
|
||||
**Goal**: 实现多平台尺寸适配和导出
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
|
||||
## 新增文件清单
|
||||
|
||||
### 后端 (Rust)
|
||||
- `src-tauri/src/database/dao/brand_persona_dao.rs` - 品牌人设 DAO
|
||||
|
||||
### 前端 (TypeScript/React)
|
||||
- `src/types/brand-persona.ts` - 品牌人设类型定义
|
||||
- `src/hooks/useBrandPersona.ts` - 品牌人设 Hook
|
||||
- `src/components/projects/dialogs/BrandPersonaDialog.tsx` - 品牌人设对话框
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 后端 (Rust)
|
||||
- `src-tauri/src/models/project_model.rs` - 新增品牌人设数据模型
|
||||
- `src-tauri/src/database/schema.rs` - 新增品牌人设扩展表
|
||||
- `src-tauri/src/database/dao/mod.rs` - 导出新 DAO
|
||||
- `src-tauri/src/services/persona_service.rs` - 新增品牌人设服务方法
|
||||
- `src-tauri/src/commands/persona_cmd.rs` - 新增品牌人设命令
|
||||
- `src-tauri/src/app/runner.rs` - 注册新命令
|
||||
|
||||
### 前端 (TypeScript/React)
|
||||
- `src/types/index.ts` - 导出新类型
|
||||
- `src/hooks/index.ts` - 导出新 Hook
|
||||
- `src/components/projects/dialogs/index.ts` - 导出新组件
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
# A2UI 表单数据持久化设计
|
||||
|
||||
## 问题背景
|
||||
|
||||
当前 A2UI 表单数据只存在于前端内存中,页面刷新或切换话题后会丢失。用户填写的表单数据需要持久化到数据库,以便重新进入时能够恢复。
|
||||
|
||||
## 数据分析
|
||||
|
||||
### 需要持久化的数据
|
||||
|
||||
1. **A2UI 响应结构** (`A2UIResponse`)
|
||||
- `id`: 响应 ID
|
||||
- `components`: 组件列表(包含表单字段定义)
|
||||
- `root`: 根组件 ID
|
||||
- `data`: 初始数据模型
|
||||
- `submitAction`: 提交动作配置
|
||||
|
||||
2. **用户填写的表单数据** (`A2UIFormData`)
|
||||
- 键值对形式,key 是组件 ID,value 是用户输入的值
|
||||
- 例如:`{ "scene": "我和同事说", "feeling": "对 go 很陌生" }`
|
||||
|
||||
3. **表单状态**
|
||||
- `submitted`: 是否已提交
|
||||
- `submittedAt`: 提交时间
|
||||
- `submittedData`: 提交时的数据快照
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 方案 A:扩展 agent_messages 表(推荐)
|
||||
|
||||
在现有 `agent_messages` 表中添加字段存储 A2UI 相关数据:
|
||||
|
||||
```sql
|
||||
-- 添加 A2UI 相关字段
|
||||
ALTER TABLE agent_messages ADD COLUMN a2ui_response_json TEXT;
|
||||
ALTER TABLE agent_messages ADD COLUMN a2ui_form_data_json TEXT;
|
||||
ALTER TABLE agent_messages ADD COLUMN a2ui_submitted INTEGER DEFAULT 0;
|
||||
ALTER TABLE agent_messages ADD COLUMN a2ui_submitted_at TEXT;
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 数据与消息紧密关联,查询简单
|
||||
- 不需要额外的表和外键
|
||||
- 迁移简单
|
||||
|
||||
**缺点**:
|
||||
- 消息表字段增多
|
||||
- 如果一条消息有多个 A2UI 表单,需要用 JSON 数组存储
|
||||
|
||||
### 方案 B:独立的 A2UI 表单表
|
||||
|
||||
创建独立的表存储 A2UI 表单数据:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS a2ui_forms (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
a2ui_response_json TEXT NOT NULL,
|
||||
form_data_json TEXT DEFAULT '{}',
|
||||
submitted INTEGER DEFAULT 0,
|
||||
submitted_at TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES agent_messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_a2ui_forms_message ON a2ui_forms(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id);
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 数据结构清晰
|
||||
- 支持一条消息多个表单
|
||||
- 便于单独查询和管理表单数据
|
||||
|
||||
**缺点**:
|
||||
- 需要额外的表和外键
|
||||
- 查询时需要 JOIN
|
||||
|
||||
## 推荐方案:方案 B
|
||||
|
||||
考虑到:
|
||||
1. 一条 AI 消息可能包含多个 A2UI 表单
|
||||
2. 表单数据需要独立更新(用户填写时实时保存)
|
||||
3. 未来可能需要表单历史版本、表单模板等功能
|
||||
|
||||
### 数据流设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 前端 (React) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ StreamingRenderer │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ A2UIRenderer ──────► onFormChange() ──────► 防抖保存 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ onSubmit() ──────────────────────────────► 提交表单 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri Commands │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ save_a2ui_form_data(form_id, form_data) │
|
||||
│ submit_a2ui_form(form_id, form_data) │
|
||||
│ get_a2ui_forms_by_session(session_id) │
|
||||
│ get_a2ui_form_by_message(message_id) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Rust Backend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ A2UIFormService │
|
||||
│ - save_form_data() │
|
||||
│ - submit_form() │
|
||||
│ - get_forms_by_session() │
|
||||
│ - get_form_by_message() │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ a2ui_forms 表 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 前端改动
|
||||
|
||||
1. **A2UIRenderer 组件**
|
||||
- 添加 `formId` prop(从后端获取或生成)
|
||||
- 添加 `initialFormData` prop(从后端加载)
|
||||
- 添加 `onFormChange` 回调(防抖保存)
|
||||
|
||||
2. **useAgentChat Hook**
|
||||
- `switchTopic` 时加载该会话的所有 A2UI 表单数据
|
||||
- 将表单数据与消息关联
|
||||
|
||||
3. **StreamingRenderer 组件**
|
||||
- 传递表单数据给 A2UIRenderer
|
||||
|
||||
### 后端改动
|
||||
|
||||
1. **数据库 Schema**
|
||||
- 添加 `a2ui_forms` 表
|
||||
|
||||
2. **Tauri Commands**
|
||||
- `save_a2ui_form_data`: 保存表单数据(防抖调用)
|
||||
- `submit_a2ui_form`: 提交表单
|
||||
- `get_a2ui_forms_by_session`: 获取会话的所有表单
|
||||
- `create_a2ui_form`: 创建新表单记录
|
||||
|
||||
3. **消息保存逻辑**
|
||||
- 保存 AI 消息时,解析 A2UI 内容并创建表单记录
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Phase 1: 数据库层
|
||||
1. 添加 `a2ui_forms` 表到 schema.rs
|
||||
2. 创建 A2UIFormDao
|
||||
|
||||
### Phase 2: 后端服务
|
||||
1. 创建 A2UIFormService
|
||||
2. 添加 Tauri Commands
|
||||
|
||||
### Phase 3: 前端集成
|
||||
1. 添加 API 调用函数
|
||||
2. 修改 A2UIRenderer 支持数据持久化
|
||||
3. 修改 useAgentChat 加载表单数据
|
||||
|
||||
### Phase 4: 测试和优化
|
||||
1. 测试表单数据保存和恢复
|
||||
2. 优化防抖保存策略
|
||||
3. 处理边界情况(网络错误、并发等)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **防抖保存**:用户输入时不要每次都保存,使用 500ms 防抖
|
||||
2. **乐观更新**:先更新 UI,后台异步保存
|
||||
3. **错误处理**:保存失败时提示用户,但不阻塞操作
|
||||
4. **数据清理**:删除会话时级联删除表单数据
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.56.0",
|
||||
"version": "0.57.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+16
-16
@@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -2112,7 +2112,7 @@ dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"matches",
|
||||
"phf 0.8.0",
|
||||
"phf 0.10.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
@@ -2128,7 +2128,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.8.0",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -3988,7 +3988,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.56.0",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5307,7 +5307,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6024,9 +6024,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
|
||||
dependencies = [
|
||||
"phf_macros 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6035,7 +6033,9 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
|
||||
dependencies = [
|
||||
"phf_macros 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6139,12 +6139,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
|
||||
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
|
||||
dependencies = [
|
||||
"phf_generator 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6545,7 +6545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.12.1",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6553,7 +6553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -6635,7 +6635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -6651,7 +6651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7969,7 +7969,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"dirs 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
edition = "2021"
|
||||
authors = ["you"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -164,7 +164,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -297,4 +297,6 @@ tempfile.workspace = true
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
# 本地 Whisper 语音识别(编译很慢,CI 默认不启用)
|
||||
local-whisper = ["voice-core/local-whisper"]
|
||||
notification = [] # 预留特性:系统通知功能
|
||||
|
||||
@@ -6,12 +6,17 @@ description = "语音输入核心库 - 音频录制、语音识别、文字输
|
||||
authors = ["ProxyCast Team"]
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 本地 Whisper 识别(编译很慢,CI 默认不启用)
|
||||
local-whisper = ["dep:whisper-rs"]
|
||||
|
||||
[dependencies]
|
||||
# 音频录制
|
||||
cpal = "0.15"
|
||||
|
||||
# Whisper 本地识别
|
||||
whisper-rs = "0.12"
|
||||
# Whisper 本地识别(可选,编译耗时)
|
||||
whisper-rs = { version = "0.12", optional = true }
|
||||
|
||||
# WAV 处理
|
||||
hound = "3.5"
|
||||
|
||||
@@ -7,11 +7,13 @@ pub mod asr_client;
|
||||
pub mod error;
|
||||
pub mod output;
|
||||
pub mod recorder;
|
||||
#[cfg(feature = "local-whisper")]
|
||||
pub mod transcriber;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{Result, VoiceError};
|
||||
pub use output::OutputHandler;
|
||||
pub use recorder::AudioRecorder;
|
||||
#[cfg(feature = "local-whisper")]
|
||||
pub use transcriber::WhisperTranscriber;
|
||||
pub use types::*;
|
||||
|
||||
@@ -1259,6 +1259,14 @@ pub fn run() {
|
||||
commands::persona_cmd::set_default_persona,
|
||||
commands::persona_cmd::list_persona_templates,
|
||||
commands::persona_cmd::get_default_persona,
|
||||
commands::persona_cmd::generate_persona,
|
||||
// Brand Persona commands
|
||||
commands::persona_cmd::get_brand_persona,
|
||||
commands::persona_cmd::get_brand_extension,
|
||||
commands::persona_cmd::save_brand_extension,
|
||||
commands::persona_cmd::update_brand_extension,
|
||||
commands::persona_cmd::delete_brand_extension,
|
||||
commands::persona_cmd::list_brand_persona_templates,
|
||||
// Material commands
|
||||
commands::material_cmd::upload_material,
|
||||
commands::material_cmd::list_materials,
|
||||
@@ -1268,6 +1276,15 @@ pub fn run() {
|
||||
commands::material_cmd::get_material_content,
|
||||
commands::material_cmd::get_material_count,
|
||||
commands::material_cmd::get_materials_content,
|
||||
// Poster Material commands
|
||||
commands::poster_material_cmd::create_poster_metadata,
|
||||
commands::poster_material_cmd::get_poster_metadata,
|
||||
commands::poster_material_cmd::get_poster_material,
|
||||
commands::poster_material_cmd::list_by_image_category,
|
||||
commands::poster_material_cmd::list_by_layout_category,
|
||||
commands::poster_material_cmd::list_by_mood,
|
||||
commands::poster_material_cmd::update_poster_metadata,
|
||||
commands::poster_material_cmd::delete_poster_metadata,
|
||||
// Template commands
|
||||
commands::template_cmd::create_template,
|
||||
commands::template_cmd::list_templates,
|
||||
@@ -1276,6 +1293,14 @@ pub fn run() {
|
||||
commands::template_cmd::delete_template,
|
||||
commands::template_cmd::set_default_template,
|
||||
commands::template_cmd::get_default_template,
|
||||
// A2UI Form commands
|
||||
commands::a2ui_form_cmd::create_a2ui_form,
|
||||
commands::a2ui_form_cmd::get_a2ui_form,
|
||||
commands::a2ui_form_cmd::get_a2ui_forms_by_message,
|
||||
commands::a2ui_form_cmd::get_a2ui_forms_by_session,
|
||||
commands::a2ui_form_cmd::save_a2ui_form_data,
|
||||
commands::a2ui_form_cmd::submit_a2ui_form,
|
||||
commands::a2ui_form_cmd::delete_a2ui_form,
|
||||
// Content commands
|
||||
commands::content_cmd::content_create,
|
||||
commands::content_cmd::content_get,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! A2UI 表单 Tauri 命令
|
||||
//!
|
||||
//! 提供 A2UI 表单的前端 API,包括:
|
||||
//! - 创建表单记录
|
||||
//! - 保存表单数据
|
||||
//! - 提交表单
|
||||
//! - 查询表单
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::dao::a2ui_form_dao::{
|
||||
A2UIForm, A2UIFormDao, A2UIFormError, CreateA2UIFormRequest,
|
||||
};
|
||||
use crate::database::DbConnection;
|
||||
|
||||
// ============================================================================
|
||||
// 响应类型
|
||||
// ============================================================================
|
||||
|
||||
/// 命令结果类型
|
||||
type CmdResult<T> = Result<T, String>;
|
||||
|
||||
/// 将 A2UIFormError 转换为字符串
|
||||
fn map_err(e: A2UIFormError) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 命令
|
||||
// ============================================================================
|
||||
|
||||
/// 创建 A2UI 表单记录
|
||||
#[tauri::command]
|
||||
pub async fn create_a2ui_form(
|
||||
db: State<'_, DbConnection>,
|
||||
message_id: i64,
|
||||
session_id: String,
|
||||
a2ui_response_json: String,
|
||||
form_data_json: Option<String>,
|
||||
) -> CmdResult<A2UIForm> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
|
||||
let req = CreateA2UIFormRequest {
|
||||
message_id,
|
||||
session_id,
|
||||
a2ui_response_json,
|
||||
form_data_json,
|
||||
};
|
||||
|
||||
A2UIFormDao::create(&conn, &req).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 获取单个表单
|
||||
#[tauri::command]
|
||||
pub async fn get_a2ui_form(db: State<'_, DbConnection>, id: String) -> CmdResult<Option<A2UIForm>> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::get(&conn, &id).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 根据消息 ID 获取表单列表
|
||||
#[tauri::command]
|
||||
pub async fn get_a2ui_forms_by_message(
|
||||
db: State<'_, DbConnection>,
|
||||
message_id: i64,
|
||||
) -> CmdResult<Vec<A2UIForm>> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::get_by_message(&conn, message_id).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 根据会话 ID 获取所有表单
|
||||
#[tauri::command]
|
||||
pub async fn get_a2ui_forms_by_session(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> CmdResult<Vec<A2UIForm>> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::get_by_session(&conn, &session_id).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 更新表单数据(用户填写的内容)
|
||||
#[tauri::command]
|
||||
pub async fn save_a2ui_form_data(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
form_data_json: String,
|
||||
) -> CmdResult<A2UIForm> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::update_form_data(&conn, &id, &form_data_json).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 提交表单
|
||||
#[tauri::command]
|
||||
pub async fn submit_a2ui_form(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
form_data_json: String,
|
||||
) -> CmdResult<A2UIForm> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::submit(&conn, &id, &form_data_json).map_err(map_err)
|
||||
}
|
||||
|
||||
/// 删除表单
|
||||
#[tauri::command]
|
||||
pub async fn delete_a2ui_form(db: State<'_, DbConnection>, id: String) -> CmdResult<()> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
A2UIFormDao::delete(&conn, &id).map_err(map_err)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod a2ui_form_cmd;
|
||||
pub mod agent_cmd;
|
||||
pub mod api_key_provider_cmd;
|
||||
pub mod asr_cmd;
|
||||
@@ -28,6 +29,7 @@ pub mod persona_cmd;
|
||||
pub mod plugin_cmd;
|
||||
pub mod plugin_install_cmd;
|
||||
pub mod plugin_rpc_cmd;
|
||||
pub mod poster_material_cmd;
|
||||
pub mod prompt_cmd;
|
||||
pub mod provider_pool_cmd;
|
||||
pub mod resilience_cmd;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! - 创建、获取、列表、更新、删除人设
|
||||
//! - 设置项目默认人设
|
||||
//! - 获取人设模板列表
|
||||
//! - AI 一键生成人设
|
||||
//! - 品牌人设扩展管理
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 6.1: 人设列表显示
|
||||
@@ -12,11 +14,16 @@
|
||||
//! - Requirements 6.4: 设置默认人设
|
||||
//! - Requirements 6.5: 人设模板
|
||||
//! - Requirements 6.6: 人设删除确认
|
||||
//! - Requirements 6.7: AI 一键生成人设
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate};
|
||||
use crate::models::project_model::{
|
||||
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest,
|
||||
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
|
||||
};
|
||||
use crate::services::persona_service::PersonaService;
|
||||
|
||||
// ============================================================================
|
||||
@@ -173,28 +180,21 @@ pub async fn delete_persona(db: State<'_, DbConnection>, id: String) -> Result<(
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `persona_id`: 要设为默认的人设 ID
|
||||
/// - `projectId`: 项目 ID
|
||||
/// - `personaId`: 要设为默认的人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('set_default_persona', {
|
||||
/// projectId: 'project-1',
|
||||
/// personaId: 'persona-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn set_default_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
persona_id: String,
|
||||
projectId: String,
|
||||
personaId: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::set_default_persona(&conn, &project_id, &persona_id).map_err(|e| e.to_string())
|
||||
PersonaService::set_default_persona(&conn, &projectId, &personaId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取人设模板列表
|
||||
@@ -240,3 +240,349 @@ pub async fn get_default_persona(
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::get_default_persona(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI 生成人设
|
||||
// ============================================================================
|
||||
|
||||
/// AI 生成的人设结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GeneratedPersona {
|
||||
/// 人设名称
|
||||
pub name: String,
|
||||
/// 人设描述
|
||||
pub description: String,
|
||||
/// 写作风格
|
||||
pub style: String,
|
||||
/// 语气
|
||||
pub tone: String,
|
||||
/// 目标受众
|
||||
pub target_audience: String,
|
||||
/// 禁用词列表
|
||||
pub forbidden_words: Vec<String>,
|
||||
/// 偏好词列表
|
||||
pub preferred_words: Vec<String>,
|
||||
}
|
||||
|
||||
/// AI 一键生成人设
|
||||
///
|
||||
/// 根据用户提供的简单描述,调用 AI 生成完整的人设配置。
|
||||
/// 自动从凭证池选择可用凭证进行调用。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `prompt`: 用户描述,例如"一个幽默风趣的科技博主"
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回生成的人设配置
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn generate_persona(
|
||||
agent_state: State<'_, crate::agent::AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
prompt: String,
|
||||
) -> Result<GeneratedPersona, String> {
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
|
||||
tracing::info!("[Persona] AI 生成人设: prompt={}", prompt);
|
||||
|
||||
// 确保 Agent 已初始化
|
||||
if !agent_state.is_initialized().await {
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
}
|
||||
|
||||
// 创建临时会话 ID
|
||||
let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4());
|
||||
|
||||
// 如果 Provider 未配置,自动从凭证池选择一个
|
||||
if !agent_state.is_provider_configured().await {
|
||||
tracing::info!("[Persona] Provider 未配置,尝试从凭证池自动选择");
|
||||
|
||||
// 尝试按优先级选择 Provider: deepseek > openai > anthropic > kiro
|
||||
let provider_types = ["deepseek", "openai", "anthropic", "kiro"];
|
||||
let default_models = [
|
||||
"deepseek-chat",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-haiku-20240307",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
];
|
||||
|
||||
let mut configured = false;
|
||||
for (provider_type, model) in provider_types.iter().zip(default_models.iter()) {
|
||||
match agent_state
|
||||
.configure_provider_from_pool(&db, provider_type, model, &session_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"[Persona] 自动配置 Provider 成功: {} / {}",
|
||||
provider_type,
|
||||
model
|
||||
);
|
||||
configured = true;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"[Persona] 尝试 {} 失败: {}, 继续尝试下一个",
|
||||
provider_type,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !configured {
|
||||
return Err("没有可用的 AI 凭证,请先在设置中添加凭证".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let system_prompt = r#"你是一个专业的内容创作人设设计师。根据用户的描述,生成一个完整的创作人设配置。
|
||||
|
||||
请严格按照以下 JSON 格式返回(不要包含任何其他文字,不要使用 markdown 代码块):
|
||||
{"name":"人设名称","description":"人设描述(50字以内)","style":"写作风格","tone":"语气","targetAudience":"目标受众","forbiddenWords":["禁用词1","禁用词2"],"preferredWords":["偏好词1","偏好词2"]}
|
||||
|
||||
注意:
|
||||
1. 名称要有特色,能体现人设特点
|
||||
2. 禁用词是创作时应避免的词汇
|
||||
3. 偏好词是创作时优先使用的词汇
|
||||
4. 直接返回 JSON,不要任何额外文字"#;
|
||||
|
||||
let user_prompt = format!("{}\n\n请为以下描述生成人设配置:{}", system_prompt, prompt);
|
||||
|
||||
let cancel_token = agent_state.create_cancel_token(&session_id).await;
|
||||
|
||||
let user_message = Message::user().with_text(&user_prompt);
|
||||
let session_config = crate::agent::aster_state::SessionConfigBuilder::new(&session_id).build();
|
||||
|
||||
// 获取 Agent 引用
|
||||
let agent_arc = agent_state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent 未初始化")?;
|
||||
|
||||
// 调用 Agent
|
||||
let stream_result = agent
|
||||
.reply(user_message, session_config, Some(cancel_token.clone()))
|
||||
.await;
|
||||
|
||||
let mut full_content = String::new();
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
while let Some(event_result) = stream.next().await {
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
// 提取文本内容
|
||||
if let aster::agents::AgentEvent::Message(message) = agent_event {
|
||||
for content in &message.content {
|
||||
if let aster::conversation::message::MessageContent::Text(
|
||||
text_content,
|
||||
) = content
|
||||
{
|
||||
full_content.push_str(&text_content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[Persona] 流错误: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
agent_state.remove_cancel_token(&session_id).await;
|
||||
return Err(format!("AI 调用失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 清理取消令牌
|
||||
agent_state.remove_cancel_token(&session_id).await;
|
||||
|
||||
if full_content.is_empty() {
|
||||
return Err("AI 返回空内容".to_string());
|
||||
}
|
||||
|
||||
tracing::debug!("[Persona] AI 返回内容: {}", full_content);
|
||||
|
||||
// 解析 AI 返回的 JSON
|
||||
let persona: GeneratedPersona = parse_persona_json(&full_content)?;
|
||||
|
||||
tracing::info!("[Persona] AI 生成人设成功: name={}", persona.name);
|
||||
|
||||
Ok(persona)
|
||||
}
|
||||
|
||||
/// 解析 AI 返回的人设 JSON
|
||||
fn parse_persona_json(content: &str) -> Result<GeneratedPersona, String> {
|
||||
// 尝试提取 JSON 部分(AI 可能返回额外文字)
|
||||
let json_str = extract_json(content);
|
||||
|
||||
serde_json::from_str(&json_str).map_err(|e| {
|
||||
tracing::error!("[Persona] JSON 解析失败: {}, content: {}", e, content);
|
||||
format!("解析人设配置失败: {e}")
|
||||
})
|
||||
}
|
||||
|
||||
/// 从文本中提取 JSON
|
||||
fn extract_json(content: &str) -> String {
|
||||
// 查找 JSON 对象的开始和结束
|
||||
if let Some(start) = content.find('{') {
|
||||
if let Some(end) = content.rfind('}') {
|
||||
if end > start {
|
||||
return content[start..=end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 品牌人设扩展命令
|
||||
// ============================================================================
|
||||
|
||||
/// 获取品牌人设(基础人设 + 扩展)
|
||||
///
|
||||
/// 获取完整的品牌人设信息,包括基础人设和品牌扩展字段。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersona>
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const brandPersona = await invoke('get_brand_persona', {
|
||||
/// personaId: 'persona-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_brand_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
persona_id: String,
|
||||
) -> Result<Option<BrandPersona>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::get_brand_persona(&conn, &persona_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取品牌人设扩展
|
||||
///
|
||||
/// 仅获取品牌扩展字段,不包括基础人设。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersonaExtension>
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn get_brand_extension(
|
||||
db: State<'_, DbConnection>,
|
||||
persona_id: String,
|
||||
) -> Result<Option<BrandPersonaExtension>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::get_brand_extension(&conn, &persona_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 保存品牌人设扩展
|
||||
///
|
||||
/// 创建或更新品牌人设扩展。如果扩展不存在则创建,存在则更新。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `req`: 创建/更新请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回保存后的扩展
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const extension = await invoke('save_brand_extension', {
|
||||
/// req: {
|
||||
/// personaId: 'persona-1',
|
||||
/// brandTone: {
|
||||
/// keywords: ['专业', '可信赖'],
|
||||
/// personality: 'professional',
|
||||
/// voiceTone: '专业但不冷漠',
|
||||
/// },
|
||||
/// design: {
|
||||
/// primaryStyle: 'modern',
|
||||
/// colorScheme: { ... },
|
||||
/// typography: { ... },
|
||||
/// },
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn save_brand_extension(
|
||||
db: State<'_, DbConnection>,
|
||||
req: CreateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::save_brand_extension(&conn, req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新品牌人设扩展
|
||||
///
|
||||
/// 更新已存在的品牌人设扩展。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `persona_id`: 人设 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的扩展
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn update_brand_extension(
|
||||
db: State<'_, DbConnection>,
|
||||
persona_id: String,
|
||||
update: UpdateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::update_brand_extension(&conn, &persona_id, update).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除品牌人设扩展
|
||||
///
|
||||
/// 删除指定人设的品牌扩展,不影响基础人设。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn delete_brand_extension(
|
||||
db: State<'_, DbConnection>,
|
||||
persona_id: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::delete_brand_extension(&conn, &persona_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取品牌人设模板列表
|
||||
///
|
||||
/// 获取预定义的品牌人设模板,用于快速创建品牌人设。
|
||||
/// 模板包含电商促销、品牌形象、社交媒体、活动宣传等场景。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 品牌人设模板列表
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const templates = await invoke('list_brand_persona_templates');
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_brand_persona_templates() -> Result<Vec<BrandPersonaTemplate>, String> {
|
||||
Ok(PersonaService::list_brand_persona_templates())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 海报素材相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供海报素材元数据(PosterMaterialMetadata)管理的前端 API,包括:
|
||||
//! - 创建、获取、更新、删除海报素材元数据
|
||||
//! - 按分类筛选素材
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::dao::poster_material_dao::PosterMaterialDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::{
|
||||
CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 命令
|
||||
// ============================================================================
|
||||
|
||||
/// 创建海报素材元数据
|
||||
///
|
||||
/// 为已存在的素材创建海报专用元数据。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `req`: 创建请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的元数据
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn create_poster_metadata(
|
||||
db: State<'_, DbConnection>,
|
||||
req: CreatePosterMetadataRequest,
|
||||
) -> Result<PosterMaterialMetadata, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::create(&conn, &req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取海报素材元数据
|
||||
///
|
||||
/// 根据素材 ID 获取海报元数据。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `material_id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PosterMaterialMetadata>
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn get_poster_metadata(
|
||||
db: State<'_, DbConnection>,
|
||||
material_id: String,
|
||||
) -> Result<Option<PosterMaterialMetadata>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取完整的海报素材
|
||||
///
|
||||
/// 获取包含基础素材和元数据的完整海报素材。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `material_id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PosterMaterial>
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn get_poster_material(
|
||||
db: State<'_, DbConnection>,
|
||||
material_id: String,
|
||||
) -> Result<Option<PosterMaterial>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::get_poster_material(&conn, &material_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 按图片分类获取素材列表
|
||||
///
|
||||
/// 获取指定项目下的图片素材,可按分类筛选。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `category`: 可选的图片分类
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回海报素材列表
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn list_by_image_category(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
category: Option<String>,
|
||||
) -> Result<Vec<PosterMaterial>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::list_by_image_category(&conn, &project_id, category.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 按布局分类获取素材列表
|
||||
///
|
||||
/// 获取指定项目下的布局素材,可按分类筛选。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `category`: 可选的布局分类
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回海报素材列表
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn list_by_layout_category(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
category: Option<String>,
|
||||
) -> Result<Vec<PosterMaterial>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::list_by_layout_category(&conn, &project_id, category.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 按配色氛围获取素材列表
|
||||
///
|
||||
/// 获取指定项目下的配色素材,可按氛围筛选。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `mood`: 可选的配色氛围
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回海报素材列表
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn list_by_mood(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
mood: Option<String>,
|
||||
) -> Result<Vec<PosterMaterial>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::list_by_mood(&conn, &project_id, mood.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新海报素材元数据
|
||||
///
|
||||
/// 更新指定素材的海报元数据。如果元数据不存在,则创建新的。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `material_id`: 素材 ID
|
||||
/// - `req`: 更新请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的元数据
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn update_poster_metadata(
|
||||
db: State<'_, DbConnection>,
|
||||
material_id: String,
|
||||
req: CreatePosterMetadataRequest,
|
||||
) -> Result<PosterMaterialMetadata, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除海报素材元数据
|
||||
///
|
||||
/// 删除指定素材的海报元数据。
|
||||
/// 注意:这只删除元数据,不删除基础素材。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `material_id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn delete_poster_metadata(
|
||||
db: State<'_, DbConnection>,
|
||||
material_id: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PosterMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! A2UI 表单数据访问层
|
||||
//!
|
||||
//! 提供 A2UI 表单的 CRUD 操作,包括:
|
||||
//! - 创建、获取、更新、删除表单
|
||||
//! - 按会话/消息查询表单
|
||||
//! - 更新表单数据和提交状态
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// 数据模型
|
||||
// ============================================================================
|
||||
|
||||
/// A2UI 表单记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct A2UIForm {
|
||||
/// 表单 ID
|
||||
pub id: String,
|
||||
/// 关联的消息 ID
|
||||
pub message_id: i64,
|
||||
/// 关联的会话 ID
|
||||
pub session_id: String,
|
||||
/// A2UI 响应 JSON(包含组件定义)
|
||||
pub a2ui_response_json: String,
|
||||
/// 用户填写的表单数据 JSON
|
||||
pub form_data_json: String,
|
||||
/// 是否已提交
|
||||
pub submitted: bool,
|
||||
/// 提交时间
|
||||
pub submitted_at: Option<String>,
|
||||
/// 创建时间
|
||||
pub created_at: i64,
|
||||
/// 更新时间
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 创建 A2UI 表单请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateA2UIFormRequest {
|
||||
/// 关联的消息 ID
|
||||
pub message_id: i64,
|
||||
/// 关联的会话 ID
|
||||
pub session_id: String,
|
||||
/// A2UI 响应 JSON
|
||||
pub a2ui_response_json: String,
|
||||
/// 初始表单数据(可选)
|
||||
pub form_data_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新表单数据请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateFormDataRequest {
|
||||
/// 表单数据 JSON
|
||||
pub form_data_json: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 错误类型
|
||||
// ============================================================================
|
||||
|
||||
/// A2UI 表单错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum A2UIFormError {
|
||||
#[error("表单不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// A2UI 表单 DAO
|
||||
pub struct A2UIFormDao;
|
||||
|
||||
impl A2UIFormDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建表单
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新的 A2UI 表单记录
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
req: &CreateA2UIFormRequest,
|
||||
) -> Result<A2UIForm, A2UIFormError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let form_data = req
|
||||
.form_data_json
|
||||
.clone()
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO a2ui_forms (
|
||||
id, message_id, session_id, a2ui_response_json, form_data_json,
|
||||
submitted, submitted_at, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
id,
|
||||
req.message_id,
|
||||
req.session_id,
|
||||
req.a2ui_response_json,
|
||||
form_data,
|
||||
0, // submitted = false
|
||||
Option::<String>::None,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(A2UIForm {
|
||||
id,
|
||||
message_id: req.message_id,
|
||||
session_id: req.session_id.clone(),
|
||||
a2ui_response_json: req.a2ui_response_json.clone(),
|
||||
form_data_json: form_data,
|
||||
submitted: false,
|
||||
submitted_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取表单
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 根据 ID 获取表单
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<A2UIForm>, A2UIFormError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, message_id, session_id, a2ui_response_json, form_data_json,
|
||||
submitted, submitted_at, created_at, updated_at
|
||||
FROM a2ui_forms WHERE id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据消息 ID 获取表单列表
|
||||
pub fn get_by_message(
|
||||
conn: &Connection,
|
||||
message_id: i64,
|
||||
) -> Result<Vec<A2UIForm>, A2UIFormError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, message_id, session_id, a2ui_response_json, form_data_json,
|
||||
submitted, submitted_at, created_at, updated_at
|
||||
FROM a2ui_forms WHERE message_id = ? ORDER BY created_at ASC",
|
||||
)?;
|
||||
|
||||
let forms: Vec<A2UIForm> = stmt
|
||||
.query_map([message_id], |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(forms)
|
||||
}
|
||||
|
||||
/// 根据会话 ID 获取所有表单
|
||||
pub fn get_by_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<A2UIForm>, A2UIFormError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, message_id, session_id, a2ui_response_json, form_data_json,
|
||||
submitted, submitted_at, created_at, updated_at
|
||||
FROM a2ui_forms WHERE session_id = ? ORDER BY created_at ASC",
|
||||
)?;
|
||||
|
||||
let forms: Vec<A2UIForm> = stmt
|
||||
.query_map([session_id], |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(forms)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新表单
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新表单数据(用户填写的内容)
|
||||
pub fn update_form_data(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
form_data_json: &str,
|
||||
) -> Result<A2UIForm, A2UIFormError> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let rows = conn.execute(
|
||||
"UPDATE a2ui_forms SET form_data_json = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![form_data_json, now, id],
|
||||
)?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(A2UIFormError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Self::get(conn, id)?.ok_or_else(|| A2UIFormError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// 提交表单
|
||||
pub fn submit(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
form_data_json: &str,
|
||||
) -> Result<A2UIForm, A2UIFormError> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let submitted_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let rows = conn.execute(
|
||||
"UPDATE a2ui_forms SET
|
||||
form_data_json = ?1,
|
||||
submitted = 1,
|
||||
submitted_at = ?2,
|
||||
updated_at = ?3
|
||||
WHERE id = ?4",
|
||||
params![form_data_json, submitted_at, now, id],
|
||||
)?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(A2UIFormError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Self::get(conn, id)?.ok_or_else(|| A2UIFormError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除表单
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除表单
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<(), A2UIFormError> {
|
||||
let rows = conn.execute("DELETE FROM a2ui_forms WHERE id = ?", [id])?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(A2UIFormError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除会话的所有表单
|
||||
pub fn delete_by_session(conn: &Connection, session_id: &str) -> Result<u64, A2UIFormError> {
|
||||
let rows = conn.execute("DELETE FROM a2ui_forms WHERE session_id = ?", [session_id])?;
|
||||
|
||||
Ok(rows as u64)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 A2UIForm 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<A2UIForm, rusqlite::Error> {
|
||||
Ok(A2UIForm {
|
||||
id: row.get(0)?,
|
||||
message_id: row.get(1)?,
|
||||
session_id: row.get(2)?,
|
||||
a2ui_response_json: row.get(3)?,
|
||||
form_data_json: row.get(4)?,
|
||||
submitted: row.get::<_, i32>(5)? != 0,
|
||||
submitted_at: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
//! 品牌人设扩展数据访问层
|
||||
//!
|
||||
//! 提供品牌人设扩展(BrandPersonaExtension)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、更新、删除品牌人设扩展
|
||||
//! - 获取完整的品牌人设(基础人设 + 扩展)
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::PersonaError;
|
||||
use crate::models::project_model::{
|
||||
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, BrandTone,
|
||||
CreateBrandExtensionRequest, DesignConfig, Persona, UpdateBrandExtensionRequest, VisualConfig,
|
||||
};
|
||||
|
||||
use super::persona_dao::PersonaDao;
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 品牌人设扩展 DAO
|
||||
///
|
||||
/// 提供品牌人设扩展的数据库操作方法。
|
||||
pub struct BrandPersonaDao;
|
||||
|
||||
impl BrandPersonaDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建品牌人设扩展
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的扩展
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
req: &CreateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, PersonaError> {
|
||||
// 验证人设存在
|
||||
PersonaDao::get(conn, &req.persona_id)?
|
||||
.ok_or_else(|| PersonaError::NotFound(req.persona_id.clone()))?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 序列化 JSON 字段
|
||||
let brand_tone = req.brand_tone.clone().unwrap_or_default();
|
||||
let design = req.design.clone().unwrap_or_default();
|
||||
let visual = req.visual.clone().unwrap_or_default();
|
||||
|
||||
let brand_tone_json =
|
||||
serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string());
|
||||
let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string());
|
||||
let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO brand_persona_extensions (
|
||||
id, persona_id, brand_tone_json, design_json, visual_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
id,
|
||||
req.persona_id,
|
||||
brand_tone_json,
|
||||
design_json,
|
||||
visual_json,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(BrandPersonaExtension {
|
||||
persona_id: req.persona_id.clone(),
|
||||
brand_tone,
|
||||
design,
|
||||
visual,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取品牌人设扩展
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersonaExtension>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<BrandPersonaExtension>, PersonaError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT persona_id, brand_tone_json, design_json, visual_json, created_at, updated_at
|
||||
FROM brand_persona_extensions WHERE persona_id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([persona_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取完整的品牌人设(基础人设 + 扩展)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_brand_persona(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<BrandPersona>, PersonaError> {
|
||||
// 获取基础人设
|
||||
let base = match PersonaDao::get(conn, persona_id)? {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// 获取扩展
|
||||
let extension = Self::get(conn, persona_id)?;
|
||||
|
||||
Ok(Some(BrandPersona {
|
||||
base,
|
||||
brand_tone: extension.as_ref().map(|e| e.brand_tone.clone()),
|
||||
design: extension.as_ref().map(|e| e.design.clone()),
|
||||
visual: extension.as_ref().map(|e| e.visual.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新品牌人设扩展
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新品牌人设扩展
|
||||
///
|
||||
/// 如果扩展不存在,则创建新的扩展。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的扩展
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
update: &UpdateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, PersonaError> {
|
||||
// 验证人设存在
|
||||
PersonaDao::get(conn, persona_id)?
|
||||
.ok_or_else(|| PersonaError::NotFound(persona_id.to_string()))?;
|
||||
|
||||
// 检查扩展是否存在
|
||||
let existing = Self::get(conn, persona_id)?;
|
||||
|
||||
if existing.is_none() {
|
||||
// 创建新扩展
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona_id.to_string(),
|
||||
brand_tone: update.brand_tone.clone(),
|
||||
design: update.design.clone(),
|
||||
visual: update.visual.clone(),
|
||||
};
|
||||
return Self::create(conn, &req);
|
||||
}
|
||||
|
||||
let existing = existing.unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 构建更新后的值
|
||||
let brand_tone = update.brand_tone.clone().unwrap_or(existing.brand_tone);
|
||||
let design = update.design.clone().unwrap_or(existing.design);
|
||||
let visual = update.visual.clone().unwrap_or(existing.visual);
|
||||
|
||||
// 序列化 JSON 字段
|
||||
let brand_tone_json =
|
||||
serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string());
|
||||
let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string());
|
||||
let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE brand_persona_extensions SET
|
||||
brand_tone_json = ?1, design_json = ?2, visual_json = ?3, updated_at = ?4
|
||||
WHERE persona_id = ?5",
|
||||
params![brand_tone_json, design_json, visual_json, now, persona_id,],
|
||||
)?;
|
||||
|
||||
Ok(BrandPersonaExtension {
|
||||
persona_id: persona_id.to_string(),
|
||||
brand_tone,
|
||||
design,
|
||||
visual,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除品牌人设扩展
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn delete(conn: &Connection, persona_id: &str) -> Result<(), PersonaError> {
|
||||
conn.execute(
|
||||
"DELETE FROM brand_persona_extensions WHERE persona_id = ?",
|
||||
[persona_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 BrandPersonaExtension 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<BrandPersonaExtension, rusqlite::Error> {
|
||||
let brand_tone_json: String = row.get(1)?;
|
||||
let design_json: String = row.get(2)?;
|
||||
let visual_json: String = row.get(3)?;
|
||||
|
||||
// 解析 JSON 字段
|
||||
let brand_tone: BrandTone = serde_json::from_str(&brand_tone_json).unwrap_or_default();
|
||||
let design: DesignConfig = serde_json::from_str(&design_json).unwrap_or_default();
|
||||
let visual: VisualConfig = serde_json::from_str(&visual_json).unwrap_or_default();
|
||||
|
||||
Ok(BrandPersonaExtension {
|
||||
persona_id: row.get(0)?,
|
||||
brand_tone,
|
||||
design,
|
||||
visual,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 品牌人设模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取预定义的品牌人设模板列表
|
||||
pub fn list_templates() -> Vec<BrandPersonaTemplate> {
|
||||
vec![
|
||||
BrandPersonaTemplate {
|
||||
id: "ecommerce-promo".to_string(),
|
||||
name: "电商促销".to_string(),
|
||||
description: "适合电商促销、限时优惠等场景".to_string(),
|
||||
brand_tone: BrandTone {
|
||||
keywords: vec!["实惠".to_string(), "限时".to_string(), "优惠".to_string()],
|
||||
personality: "bold".to_string(),
|
||||
voice_tone: Some("紧迫感、吸引力".to_string()),
|
||||
target_audience: Some("追求性价比的消费者".to_string()),
|
||||
},
|
||||
design: DesignConfig {
|
||||
primary_style: "bold".to_string(),
|
||||
color_scheme: crate::models::project_model::ColorScheme {
|
||||
primary: "#FF4757".to_string(),
|
||||
secondary: "#FFA502".to_string(),
|
||||
accent: "#FF6348".to_string(),
|
||||
background: "#FFFFFF".to_string(),
|
||||
text: "#2F3542".to_string(),
|
||||
text_secondary: "#57606F".to_string(),
|
||||
gradients: None,
|
||||
},
|
||||
typography: crate::models::project_model::Typography {
|
||||
title_font: "阿里巴巴普惠体".to_string(),
|
||||
title_weight: 700,
|
||||
body_font: "思源黑体".to_string(),
|
||||
body_weight: 400,
|
||||
title_size: 80,
|
||||
body_size: 24,
|
||||
line_height: 1.4,
|
||||
letter_spacing: 0.0,
|
||||
},
|
||||
},
|
||||
visual: None,
|
||||
},
|
||||
BrandPersonaTemplate {
|
||||
id: "brand-image".to_string(),
|
||||
name: "品牌形象".to_string(),
|
||||
description: "适合品牌宣传、企业形象展示".to_string(),
|
||||
brand_tone: BrandTone {
|
||||
keywords: vec!["专业".to_string(), "可信赖".to_string(), "品质".to_string()],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: Some("专业但不冷漠".to_string()),
|
||||
target_audience: Some("注重品质的消费者".to_string()),
|
||||
},
|
||||
design: DesignConfig {
|
||||
primary_style: "modern".to_string(),
|
||||
color_scheme: crate::models::project_model::ColorScheme {
|
||||
primary: "#2196F3".to_string(),
|
||||
secondary: "#90CAF9".to_string(),
|
||||
accent: "#1976D2".to_string(),
|
||||
background: "#FFFFFF".to_string(),
|
||||
text: "#212121".to_string(),
|
||||
text_secondary: "#757575".to_string(),
|
||||
gradients: None,
|
||||
},
|
||||
typography: crate::models::project_model::Typography {
|
||||
title_font: "思源黑体".to_string(),
|
||||
title_weight: 600,
|
||||
body_font: "苹方".to_string(),
|
||||
body_weight: 400,
|
||||
title_size: 64,
|
||||
body_size: 20,
|
||||
line_height: 1.6,
|
||||
letter_spacing: 1.0,
|
||||
},
|
||||
},
|
||||
visual: None,
|
||||
},
|
||||
BrandPersonaTemplate {
|
||||
id: "social-media".to_string(),
|
||||
name: "社交媒体".to_string(),
|
||||
description: "适合小红书、抖音等社交平台".to_string(),
|
||||
brand_tone: BrandTone {
|
||||
keywords: vec!["年轻".to_string(), "时尚".to_string(), "潮流".to_string()],
|
||||
personality: "playful".to_string(),
|
||||
voice_tone: Some("轻松活泼、有趣".to_string()),
|
||||
target_audience: Some("18-30岁年轻人".to_string()),
|
||||
},
|
||||
design: DesignConfig {
|
||||
primary_style: "playful".to_string(),
|
||||
color_scheme: crate::models::project_model::ColorScheme {
|
||||
primary: "#FF6B9D".to_string(),
|
||||
secondary: "#FFC0D0".to_string(),
|
||||
accent: "#FF4081".to_string(),
|
||||
background: "#FFFFFF".to_string(),
|
||||
text: "#333333".to_string(),
|
||||
text_secondary: "#666666".to_string(),
|
||||
gradients: None,
|
||||
},
|
||||
typography: crate::models::project_model::Typography {
|
||||
title_font: "站酷快乐体".to_string(),
|
||||
title_weight: 400,
|
||||
body_font: "思源黑体".to_string(),
|
||||
body_weight: 400,
|
||||
title_size: 72,
|
||||
body_size: 22,
|
||||
line_height: 1.5,
|
||||
letter_spacing: 0.0,
|
||||
},
|
||||
},
|
||||
visual: None,
|
||||
},
|
||||
BrandPersonaTemplate {
|
||||
id: "event-promo".to_string(),
|
||||
name: "活动宣传".to_string(),
|
||||
description: "适合活动宣传、节日促销".to_string(),
|
||||
brand_tone: BrandTone {
|
||||
keywords: vec!["热闹".to_string(), "参与".to_string(), "精彩".to_string()],
|
||||
personality: "bold".to_string(),
|
||||
voice_tone: Some("热情洋溢、感染力强".to_string()),
|
||||
target_audience: Some("活动目标参与者".to_string()),
|
||||
},
|
||||
design: DesignConfig {
|
||||
primary_style: "bold".to_string(),
|
||||
color_scheme: crate::models::project_model::ColorScheme {
|
||||
primary: "#FF9500".to_string(),
|
||||
secondary: "#FFD166".to_string(),
|
||||
accent: "#EF476F".to_string(),
|
||||
background: "#FFFFFF".to_string(),
|
||||
text: "#2D3436".to_string(),
|
||||
text_secondary: "#636E72".to_string(),
|
||||
gradients: None,
|
||||
},
|
||||
typography: crate::models::project_model::Typography {
|
||||
title_font: "站酷庆科黄油体".to_string(),
|
||||
title_weight: 400,
|
||||
body_font: "思源黑体".to_string(),
|
||||
body_weight: 400,
|
||||
title_size: 80,
|
||||
body_size: 24,
|
||||
line_height: 1.4,
|
||||
letter_spacing: 0.0,
|
||||
},
|
||||
},
|
||||
visual: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use crate::models::project_model::CreatePersonaRequest;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 创建测试人设
|
||||
fn create_test_persona(conn: &Connection, project_id: &str) -> Persona {
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: project_id.to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "专业".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
PersonaDao::create(conn, &req).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_brand_extension() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["专业".to_string(), "可信赖".to_string()],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: Some("专业但不冷漠".to_string()),
|
||||
target_audience: Some("技术人员".to_string()),
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
|
||||
let extension = BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert_eq!(extension.persona_id, persona.id);
|
||||
assert_eq!(extension.brand_tone.keywords.len(), 2);
|
||||
assert_eq!(extension.brand_tone.personality, "professional");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_brand_extension() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone::default()),
|
||||
design: Some(DesignConfig::default()),
|
||||
visual: Some(VisualConfig::default()),
|
||||
};
|
||||
|
||||
BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
let extension = BrandPersonaDao::get(&conn, &persona.id).unwrap();
|
||||
assert!(extension.is_some());
|
||||
let extension = extension.unwrap();
|
||||
assert_eq!(extension.persona_id, persona.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_extension() {
|
||||
let conn = setup_test_db();
|
||||
let result = BrandPersonaDao::get(&conn, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_brand_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 创建扩展
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["测试".to_string()],
|
||||
personality: "friendly".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 获取完整品牌人设
|
||||
let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap();
|
||||
assert!(brand_persona.is_some());
|
||||
let brand_persona = brand_persona.unwrap();
|
||||
|
||||
assert_eq!(brand_persona.base.id, persona.id);
|
||||
assert!(brand_persona.brand_tone.is_some());
|
||||
assert_eq!(brand_persona.brand_tone.unwrap().personality, "friendly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_brand_persona_without_extension() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 获取没有扩展的品牌人设
|
||||
let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap();
|
||||
assert!(brand_persona.is_some());
|
||||
let brand_persona = brand_persona.unwrap();
|
||||
|
||||
assert_eq!(brand_persona.base.id, persona.id);
|
||||
assert!(brand_persona.brand_tone.is_none());
|
||||
assert!(brand_persona.design.is_none());
|
||||
assert!(brand_persona.visual.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_brand_extension() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 创建扩展
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["原始".to_string()],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 更新扩展
|
||||
let update = UpdateBrandExtensionRequest {
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["更新".to_string(), "测试".to_string()],
|
||||
personality: "friendly".to_string(),
|
||||
voice_tone: Some("亲切".to_string()),
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
|
||||
let updated = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.brand_tone.keywords.len(), 2);
|
||||
assert_eq!(updated.brand_tone.personality, "friendly");
|
||||
assert_eq!(updated.brand_tone.voice_tone, Some("亲切".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_creates_extension_if_not_exists() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 直接更新(不先创建)
|
||||
let update = UpdateBrandExtensionRequest {
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["新建".to_string()],
|
||||
personality: "bold".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
|
||||
let result = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap();
|
||||
|
||||
assert_eq!(result.brand_tone.keywords, vec!["新建".to_string()]);
|
||||
assert_eq!(result.brand_tone.personality, "bold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_brand_extension() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 创建扩展
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone::default()),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证存在
|
||||
assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some());
|
||||
|
||||
// 删除
|
||||
BrandPersonaDao::delete(&conn, &persona.id).unwrap();
|
||||
|
||||
// 验证已删除
|
||||
assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_templates() {
|
||||
let templates = BrandPersonaDao::list_templates();
|
||||
assert_eq!(templates.len(), 4);
|
||||
|
||||
let template_ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(template_ids.contains(&"ecommerce-promo"));
|
||||
assert!(template_ids.contains(&"brand-image"));
|
||||
assert!(template_ids.contains(&"social-media"));
|
||||
assert!(template_ids.contains(&"event-promo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_delete() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let persona = create_test_persona(&conn, "project-1");
|
||||
|
||||
// 创建扩展
|
||||
let req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone::default()),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
BrandPersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证扩展存在
|
||||
assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some());
|
||||
|
||||
// 删除人设
|
||||
PersonaDao::delete(&conn, &persona.id).unwrap();
|
||||
|
||||
// 验证扩展也被删除(级联删除)
|
||||
assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod a2ui_form_dao;
|
||||
pub mod agent;
|
||||
pub mod api_key_provider;
|
||||
pub mod brand_persona_dao;
|
||||
pub mod chat;
|
||||
pub mod general_chat;
|
||||
pub mod installed_plugins;
|
||||
@@ -7,6 +9,7 @@ pub mod material_dao;
|
||||
pub mod mcp;
|
||||
pub mod orchestrator;
|
||||
pub mod persona_dao;
|
||||
pub mod poster_material_dao;
|
||||
pub mod prompts;
|
||||
pub mod provider_pool;
|
||||
pub mod providers;
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
//! 海报素材元数据数据访问层
|
||||
//!
|
||||
//! 提供海报素材元数据(PosterMaterialMetadata)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、更新、删除海报素材元数据
|
||||
//! - 按分类筛选素材
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::MaterialError;
|
||||
use crate::models::project_model::{
|
||||
CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata,
|
||||
};
|
||||
|
||||
use super::material_dao::MaterialDao;
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 海报素材元数据 DAO
|
||||
///
|
||||
/// 提供海报素材元数据的数据库操作方法。
|
||||
pub struct PosterMaterialDao;
|
||||
|
||||
impl PosterMaterialDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建元数据
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建海报素材元数据
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的元数据
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
req: &CreatePosterMetadataRequest,
|
||||
) -> Result<PosterMaterialMetadata, MaterialError> {
|
||||
// 验证素材存在
|
||||
MaterialDao::get(conn, &req.material_id)?
|
||||
.ok_or_else(|| MaterialError::NotFound(req.material_id.clone()))?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 序列化 colors
|
||||
let colors_json = serde_json::to_string(&req.colors.clone().unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO poster_material_metadata (
|
||||
id, material_id, image_category, width, height, thumbnail,
|
||||
colors_json, icon_style, icon_category, color_scheme_json,
|
||||
mood, layout_category, element_count, preview, fabric_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
id,
|
||||
req.material_id,
|
||||
req.image_category,
|
||||
req.width,
|
||||
req.height,
|
||||
req.thumbnail,
|
||||
colors_json,
|
||||
req.icon_style,
|
||||
req.icon_category,
|
||||
req.color_scheme_json,
|
||||
req.mood,
|
||||
req.layout_category,
|
||||
req.element_count,
|
||||
req.preview,
|
||||
req.fabric_json,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(PosterMaterialMetadata {
|
||||
material_id: req.material_id.clone(),
|
||||
image_category: req.image_category.clone(),
|
||||
width: req.width,
|
||||
height: req.height,
|
||||
thumbnail: req.thumbnail.clone(),
|
||||
colors: req.colors.clone().unwrap_or_default(),
|
||||
icon_style: req.icon_style.clone(),
|
||||
icon_category: req.icon_category.clone(),
|
||||
color_scheme_json: req.color_scheme_json.clone(),
|
||||
mood: req.mood.clone(),
|
||||
layout_category: req.layout_category.clone(),
|
||||
element_count: req.element_count,
|
||||
preview: req.preview.clone(),
|
||||
fabric_json: req.fabric_json.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取元数据
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取海报素材元数据
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `material_id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PosterMaterialMetadata>
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn get(
|
||||
conn: &Connection,
|
||||
material_id: &str,
|
||||
) -> Result<Option<PosterMaterialMetadata>, MaterialError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT material_id, image_category, width, height, thumbnail,
|
||||
colors_json, icon_style, icon_category, color_scheme_json,
|
||||
mood, layout_category, element_count, preview, fabric_json,
|
||||
created_at, updated_at
|
||||
FROM poster_material_metadata WHERE material_id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([material_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取完整的海报素材(基础素材 + 元数据)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `material_id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PosterMaterial>
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn get_poster_material(
|
||||
conn: &Connection,
|
||||
material_id: &str,
|
||||
) -> Result<Option<PosterMaterial>, MaterialError> {
|
||||
// 获取基础素材
|
||||
let base = match MaterialDao::get(conn, material_id)? {
|
||||
Some(m) => m,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// 获取元数据
|
||||
let metadata = Self::get(conn, material_id)?;
|
||||
|
||||
Ok(Some(PosterMaterial { base, metadata }))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 列表查询
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 按图片分类获取素材列表
|
||||
pub fn list_by_image_category(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
category: Option<&str>,
|
||||
) -> Result<Vec<PosterMaterial>, MaterialError> {
|
||||
let sql = if category.is_some() {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'image' AND pm.image_category = ?2
|
||||
ORDER BY m.created_at DESC"
|
||||
} else {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'image'
|
||||
ORDER BY m.created_at DESC"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
|
||||
let results: Vec<PosterMaterial> = if let Some(cat) = category {
|
||||
stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
} else {
|
||||
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 按布局分类获取素材列表
|
||||
pub fn list_by_layout_category(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
category: Option<&str>,
|
||||
) -> Result<Vec<PosterMaterial>, MaterialError> {
|
||||
let sql = if category.is_some() {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'layout' AND pm.layout_category = ?2
|
||||
ORDER BY m.created_at DESC"
|
||||
} else {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'layout'
|
||||
ORDER BY m.created_at DESC"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
|
||||
let results: Vec<PosterMaterial> = if let Some(cat) = category {
|
||||
stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
} else {
|
||||
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 按配色氛围获取素材列表
|
||||
pub fn list_by_mood(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
mood: Option<&str>,
|
||||
) -> Result<Vec<PosterMaterial>, MaterialError> {
|
||||
let sql = if mood.is_some() {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'color' AND pm.mood = ?2
|
||||
ORDER BY m.created_at DESC"
|
||||
} else {
|
||||
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
|
||||
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
|
||||
pm.material_id, pm.image_category, pm.width, pm.height, pm.thumbnail,
|
||||
pm.colors_json, pm.icon_style, pm.icon_category, pm.color_scheme_json,
|
||||
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
|
||||
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
|
||||
FROM materials m
|
||||
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
|
||||
WHERE m.project_id = ?1 AND m.material_type = 'color'
|
||||
ORDER BY m.created_at DESC"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
|
||||
let results: Vec<PosterMaterial> = if let Some(m) = mood {
|
||||
stmt.query_map(params![project_id, m], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
} else {
|
||||
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新元数据
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新海报素材元数据
|
||||
///
|
||||
/// 如果元数据不存在,则创建新的元数据。
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
material_id: &str,
|
||||
req: &CreatePosterMetadataRequest,
|
||||
) -> Result<PosterMaterialMetadata, MaterialError> {
|
||||
// 检查元数据是否存在
|
||||
let existing = Self::get(conn, material_id)?;
|
||||
|
||||
if existing.is_none() {
|
||||
// 创建新元数据
|
||||
return Self::create(conn, req);
|
||||
}
|
||||
|
||||
let existing = existing.unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 构建更新后的值
|
||||
let image_category = req.image_category.clone().or(existing.image_category);
|
||||
let width = req.width.or(existing.width);
|
||||
let height = req.height.or(existing.height);
|
||||
let thumbnail = req.thumbnail.clone().or(existing.thumbnail);
|
||||
let colors = req.colors.clone().unwrap_or(existing.colors);
|
||||
let icon_style = req.icon_style.clone().or(existing.icon_style);
|
||||
let icon_category = req.icon_category.clone().or(existing.icon_category);
|
||||
let color_scheme_json = req.color_scheme_json.clone().or(existing.color_scheme_json);
|
||||
let mood = req.mood.clone().or(existing.mood);
|
||||
let layout_category = req.layout_category.clone().or(existing.layout_category);
|
||||
let element_count = req.element_count.or(existing.element_count);
|
||||
let preview = req.preview.clone().or(existing.preview);
|
||||
let fabric_json = req.fabric_json.clone().or(existing.fabric_json);
|
||||
|
||||
let colors_json = serde_json::to_string(&colors).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE poster_material_metadata SET
|
||||
image_category = ?1, width = ?2, height = ?3, thumbnail = ?4,
|
||||
colors_json = ?5, icon_style = ?6, icon_category = ?7,
|
||||
color_scheme_json = ?8, mood = ?9, layout_category = ?10,
|
||||
element_count = ?11, preview = ?12, fabric_json = ?13, updated_at = ?14
|
||||
WHERE material_id = ?15",
|
||||
params![
|
||||
image_category,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
colors_json,
|
||||
icon_style,
|
||||
icon_category,
|
||||
color_scheme_json,
|
||||
mood,
|
||||
layout_category,
|
||||
element_count,
|
||||
preview,
|
||||
fabric_json,
|
||||
now,
|
||||
material_id,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(PosterMaterialMetadata {
|
||||
material_id: material_id.to_string(),
|
||||
image_category,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
colors,
|
||||
icon_style,
|
||||
icon_category,
|
||||
color_scheme_json,
|
||||
mood,
|
||||
layout_category,
|
||||
element_count,
|
||||
preview,
|
||||
fabric_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除元数据
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除海报素材元数据
|
||||
pub fn delete(conn: &Connection, material_id: &str) -> Result<(), MaterialError> {
|
||||
conn.execute(
|
||||
"DELETE FROM poster_material_metadata WHERE material_id = ?",
|
||||
[material_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 PosterMaterialMetadata 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<PosterMaterialMetadata, rusqlite::Error> {
|
||||
let colors_json: String = row.get(5)?;
|
||||
let colors: Vec<String> = serde_json::from_str(&colors_json).unwrap_or_default();
|
||||
|
||||
Ok(PosterMaterialMetadata {
|
||||
material_id: row.get(0)?,
|
||||
image_category: row.get(1)?,
|
||||
width: row.get(2)?,
|
||||
height: row.get(3)?,
|
||||
thumbnail: row.get(4)?,
|
||||
colors,
|
||||
icon_style: row.get(6)?,
|
||||
icon_category: row.get(7)?,
|
||||
color_scheme_json: row.get(8)?,
|
||||
mood: row.get(9)?,
|
||||
layout_category: row.get(10)?,
|
||||
element_count: row.get(11)?,
|
||||
preview: row.get(12)?,
|
||||
fabric_json: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 映射联合查询的数据库行到 PosterMaterial 结构体
|
||||
fn map_joined_row(row: &rusqlite::Row) -> Result<PosterMaterial, rusqlite::Error> {
|
||||
use crate::models::project_model::Material;
|
||||
|
||||
// 解析基础素材
|
||||
let tags_json: String = row.get(8)?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
|
||||
|
||||
let base = Material {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
material_type: row.get(3)?,
|
||||
file_path: row.get(4)?,
|
||||
file_size: row.get(5)?,
|
||||
mime_type: row.get(6)?,
|
||||
content: row.get(7)?,
|
||||
tags,
|
||||
description: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
};
|
||||
|
||||
// 解析元数据(可能为空)
|
||||
let metadata_material_id: Option<String> = row.get(11)?;
|
||||
let metadata = if metadata_material_id.is_some() {
|
||||
let colors_json: String = row.get(16)?;
|
||||
let colors: Vec<String> = serde_json::from_str(&colors_json).unwrap_or_default();
|
||||
|
||||
Some(PosterMaterialMetadata {
|
||||
material_id: metadata_material_id.unwrap(),
|
||||
image_category: row.get(12)?,
|
||||
width: row.get(13)?,
|
||||
height: row.get(14)?,
|
||||
thumbnail: row.get(15)?,
|
||||
colors,
|
||||
icon_style: row.get(17)?,
|
||||
icon_category: row.get(18)?,
|
||||
color_scheme_json: row.get(19)?,
|
||||
mood: row.get(20)?,
|
||||
layout_category: row.get(21)?,
|
||||
element_count: row.get(22)?,
|
||||
preview: row.get(23)?,
|
||||
fabric_json: row.get(24)?,
|
||||
created_at: row.get(25)?,
|
||||
updated_at: row.get(26)?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(PosterMaterial { base, metadata })
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use crate::models::project_model::UploadMaterialRequest;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 创建测试素材
|
||||
fn create_test_material(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
material_type: &str,
|
||||
) -> crate::models::project_model::Material {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: project_id.to_string(),
|
||||
name: "测试素材".to_string(),
|
||||
material_type: material_type.to_string(),
|
||||
file_path: None,
|
||||
content: Some("test content".to_string()),
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(conn, &req).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_poster_metadata() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let material = create_test_material(&conn, "project-1", "image");
|
||||
|
||||
let req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("background".to_string()),
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
thumbnail: Some("thumb.jpg".to_string()),
|
||||
colors: Some(vec!["#FF0000".to_string(), "#00FF00".to_string()]),
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
|
||||
let metadata = PosterMaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert_eq!(metadata.material_id, material.id);
|
||||
assert_eq!(metadata.image_category, Some("background".to_string()));
|
||||
assert_eq!(metadata.width, Some(1920));
|
||||
assert_eq!(metadata.height, Some(1080));
|
||||
assert_eq!(metadata.colors.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_poster_metadata() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let material = create_test_material(&conn, "project-1", "image");
|
||||
|
||||
let req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("product".to_string()),
|
||||
width: Some(800),
|
||||
height: Some(600),
|
||||
thumbnail: None,
|
||||
colors: None,
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
|
||||
PosterMaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
let metadata = PosterMaterialDao::get(&conn, &material.id).unwrap();
|
||||
assert!(metadata.is_some());
|
||||
let metadata = metadata.unwrap();
|
||||
assert_eq!(metadata.image_category, Some("product".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_poster_material() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let material = create_test_material(&conn, "project-1", "image");
|
||||
|
||||
let req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("decoration".to_string()),
|
||||
width: Some(500),
|
||||
height: Some(500),
|
||||
thumbnail: None,
|
||||
colors: Some(vec!["#0000FF".to_string()]),
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
|
||||
PosterMaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
let poster_material = PosterMaterialDao::get_poster_material(&conn, &material.id).unwrap();
|
||||
assert!(poster_material.is_some());
|
||||
let poster_material = poster_material.unwrap();
|
||||
|
||||
assert_eq!(poster_material.base.id, material.id);
|
||||
assert!(poster_material.metadata.is_some());
|
||||
assert_eq!(
|
||||
poster_material.metadata.unwrap().image_category,
|
||||
Some("decoration".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_poster_metadata() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let material = create_test_material(&conn, "project-1", "image");
|
||||
|
||||
// 创建初始元数据
|
||||
let req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("background".to_string()),
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
thumbnail: None,
|
||||
colors: None,
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
PosterMaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 更新元数据
|
||||
let update_req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("product".to_string()),
|
||||
width: None,
|
||||
height: None,
|
||||
thumbnail: Some("new_thumb.jpg".to_string()),
|
||||
colors: Some(vec!["#FFFFFF".to_string()]),
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
|
||||
let updated = PosterMaterialDao::update(&conn, &material.id, &update_req).unwrap();
|
||||
|
||||
assert_eq!(updated.image_category, Some("product".to_string()));
|
||||
assert_eq!(updated.width, Some(1920)); // 保留原值
|
||||
assert_eq!(updated.thumbnail, Some("new_thumb.jpg".to_string()));
|
||||
assert_eq!(updated.colors, vec!["#FFFFFF".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_poster_metadata() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
let material = create_test_material(&conn, "project-1", "image");
|
||||
|
||||
let req = CreatePosterMetadataRequest {
|
||||
material_id: material.id.clone(),
|
||||
image_category: Some("texture".to_string()),
|
||||
width: None,
|
||||
height: None,
|
||||
thumbnail: None,
|
||||
colors: None,
|
||||
icon_style: None,
|
||||
icon_category: None,
|
||||
color_scheme_json: None,
|
||||
mood: None,
|
||||
layout_category: None,
|
||||
element_count: None,
|
||||
preview: None,
|
||||
fabric_json: None,
|
||||
};
|
||||
PosterMaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证存在
|
||||
assert!(PosterMaterialDao::get(&conn, &material.id)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// 删除
|
||||
PosterMaterialDao::delete(&conn, &material.id).unwrap();
|
||||
|
||||
// 验证已删除
|
||||
assert!(PosterMaterialDao::get(&conn, &material.id)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -870,6 +870,111 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// A2UI 表单数据表
|
||||
// 存储 AI 生成的交互式表单及用户填写的数据
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS a2ui_forms (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
a2ui_response_json TEXT NOT NULL,
|
||||
form_data_json TEXT DEFAULT '{}',
|
||||
submitted INTEGER DEFAULT 0,
|
||||
submitted_at TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES agent_messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 a2ui_forms 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_a2ui_forms_message ON a2ui_forms(message_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 品牌人设扩展表 (BrandPersonaExtension)
|
||||
// 存储品牌人设的海报设计专用字段,与 personas 表关联
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS brand_persona_extensions (
|
||||
id TEXT PRIMARY KEY,
|
||||
persona_id TEXT NOT NULL UNIQUE,
|
||||
brand_tone_json TEXT NOT NULL DEFAULT '{}',
|
||||
design_json TEXT NOT NULL DEFAULT '{}',
|
||||
visual_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (persona_id) REFERENCES personas(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 brand_persona_extensions 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_brand_persona_extensions_persona_id ON brand_persona_extensions(persona_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 海报素材元数据表 (PosterMaterialMetadata)
|
||||
// 存储海报素材的扩展信息,与 materials 表关联
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS poster_material_metadata (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL UNIQUE,
|
||||
image_category TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
thumbnail TEXT,
|
||||
colors_json TEXT NOT NULL DEFAULT '[]',
|
||||
icon_style TEXT,
|
||||
icon_category TEXT,
|
||||
color_scheme_json TEXT,
|
||||
mood TEXT,
|
||||
layout_category TEXT,
|
||||
element_count INTEGER,
|
||||
preview TEXT,
|
||||
fabric_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 poster_material_metadata 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_material_id ON poster_material_metadata(material_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_image_category ON poster_material_metadata(image_category)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_icon_category ON poster_material_metadata(icon_category)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_layout_category ON poster_material_metadata(layout_category)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_mood ON poster_material_metadata(mood)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct Persona {
|
||||
|
||||
/// 创建人设请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePersonaRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
@@ -163,6 +164,12 @@ pub enum MaterialType {
|
||||
Data,
|
||||
/// 链接
|
||||
Link,
|
||||
/// 图标(海报扩展)
|
||||
Icon,
|
||||
/// 配色方案(海报扩展)
|
||||
Color,
|
||||
/// 布局模板(海报扩展)
|
||||
Layout,
|
||||
}
|
||||
|
||||
impl Default for MaterialType {
|
||||
@@ -179,6 +186,9 @@ impl MaterialType {
|
||||
MaterialType::Text => "text",
|
||||
MaterialType::Data => "data",
|
||||
MaterialType::Link => "link",
|
||||
MaterialType::Icon => "icon",
|
||||
MaterialType::Color => "color",
|
||||
MaterialType::Layout => "layout",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +199,253 @@ impl MaterialType {
|
||||
"text" => MaterialType::Text,
|
||||
"data" => MaterialType::Data,
|
||||
"link" => MaterialType::Link,
|
||||
"icon" => MaterialType::Icon,
|
||||
"color" => MaterialType::Color,
|
||||
"layout" => MaterialType::Layout,
|
||||
_ => MaterialType::Document,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为海报素材类型
|
||||
pub fn is_poster_material(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
MaterialType::Image | MaterialType::Icon | MaterialType::Color | MaterialType::Layout
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片分类
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImageCategory {
|
||||
/// 背景图
|
||||
Background,
|
||||
/// 产品图
|
||||
Product,
|
||||
/// 人物图
|
||||
Person,
|
||||
/// 装饰图
|
||||
Decoration,
|
||||
/// 纹理图
|
||||
Texture,
|
||||
/// 其他
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Default for ImageCategory {
|
||||
fn default() -> Self {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ImageCategory::Background => "background",
|
||||
ImageCategory::Product => "product",
|
||||
ImageCategory::Person => "person",
|
||||
ImageCategory::Decoration => "decoration",
|
||||
ImageCategory::Texture => "texture",
|
||||
ImageCategory::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"background" => ImageCategory::Background,
|
||||
"product" => ImageCategory::Product,
|
||||
"person" => ImageCategory::Person,
|
||||
"decoration" => ImageCategory::Decoration,
|
||||
"texture" => ImageCategory::Texture,
|
||||
_ => ImageCategory::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ImageCategory::Background => "背景",
|
||||
ImageCategory::Product => "产品",
|
||||
ImageCategory::Person => "人物",
|
||||
ImageCategory::Decoration => "装饰",
|
||||
ImageCategory::Texture => "纹理",
|
||||
ImageCategory::Other => "其他",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 布局分类
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LayoutCategory {
|
||||
/// 大图型
|
||||
HeroImage,
|
||||
/// 文字主导
|
||||
TextDominant,
|
||||
/// 网格型
|
||||
Grid,
|
||||
/// 分割型
|
||||
Split,
|
||||
/// 极简型
|
||||
Minimal,
|
||||
/// 拼贴型
|
||||
Collage,
|
||||
}
|
||||
|
||||
impl Default for LayoutCategory {
|
||||
fn default() -> Self {
|
||||
Self::HeroImage
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LayoutCategory::HeroImage => "hero-image",
|
||||
LayoutCategory::TextDominant => "text-dominant",
|
||||
LayoutCategory::Grid => "grid",
|
||||
LayoutCategory::Split => "split",
|
||||
LayoutCategory::Minimal => "minimal",
|
||||
LayoutCategory::Collage => "collage",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"hero-image" => LayoutCategory::HeroImage,
|
||||
"text-dominant" => LayoutCategory::TextDominant,
|
||||
"grid" => LayoutCategory::Grid,
|
||||
"split" => LayoutCategory::Split,
|
||||
"minimal" => LayoutCategory::Minimal,
|
||||
"collage" => LayoutCategory::Collage,
|
||||
_ => LayoutCategory::HeroImage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
LayoutCategory::HeroImage => "大图型",
|
||||
LayoutCategory::TextDominant => "文字型",
|
||||
LayoutCategory::Grid => "网格型",
|
||||
LayoutCategory::Split => "分割型",
|
||||
LayoutCategory::Minimal => "极简型",
|
||||
LayoutCategory::Collage => "拼贴型",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 海报素材元数据
|
||||
///
|
||||
/// 存储海报素材的扩展信息,与 materials 表关联。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PosterMaterialMetadata {
|
||||
/// 关联的素材 ID
|
||||
pub material_id: String,
|
||||
/// 图片分类(仅 image 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_category: Option<String>,
|
||||
/// 图片宽度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<i32>,
|
||||
/// 图片高度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<i32>,
|
||||
/// 缩略图路径或 base64
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
/// 主色列表(JSON 数组)
|
||||
#[serde(default)]
|
||||
pub colors: Vec<String>,
|
||||
/// 图标风格(仅 icon 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_style: Option<String>,
|
||||
/// 图标分类(仅 icon 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_category: Option<String>,
|
||||
/// 配色方案数据(仅 color 类型,JSON)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_scheme_json: Option<String>,
|
||||
/// 配色氛围(仅 color 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mood: Option<String>,
|
||||
/// 布局分类(仅 layout 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layout_category: Option<String>,
|
||||
/// 布局元素数量(仅 layout 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub element_count: Option<i32>,
|
||||
/// 布局预览图
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
/// Fabric.js JSON(仅 layout 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fabric_json: Option<String>,
|
||||
/// 创建时间
|
||||
pub created_at: i64,
|
||||
/// 更新时间
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 创建海报素材元数据请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePosterMetadataRequest {
|
||||
/// 关联的素材 ID
|
||||
pub material_id: String,
|
||||
/// 图片分类
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_category: Option<String>,
|
||||
/// 图片宽度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<i32>,
|
||||
/// 图片高度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<i32>,
|
||||
/// 缩略图
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
/// 主色列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub colors: Option<Vec<String>>,
|
||||
/// 图标风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_style: Option<String>,
|
||||
/// 图标分类
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_category: Option<String>,
|
||||
/// 配色方案 JSON
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_scheme_json: Option<String>,
|
||||
/// 配色氛围
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mood: Option<String>,
|
||||
/// 布局分类
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layout_category: Option<String>,
|
||||
/// 布局元素数量
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub element_count: Option<i32>,
|
||||
/// 布局预览图
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
/// Fabric.js JSON
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fabric_json: Option<String>,
|
||||
}
|
||||
|
||||
/// 海报素材(完整视图)
|
||||
///
|
||||
/// 包含基础素材和海报扩展元数据的完整数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PosterMaterial {
|
||||
/// 基础素材
|
||||
#[serde(flatten)]
|
||||
pub base: Material,
|
||||
/// 海报元数据
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<PosterMaterialMetadata>,
|
||||
}
|
||||
|
||||
/// 素材
|
||||
@@ -233,6 +487,7 @@ pub struct Material {
|
||||
|
||||
/// 上传素材请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UploadMaterialRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
@@ -271,6 +526,7 @@ pub struct MaterialUpdate {
|
||||
|
||||
/// 素材筛选条件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MaterialFilter {
|
||||
/// 按类型筛选
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
@@ -427,6 +683,7 @@ pub struct Template {
|
||||
|
||||
/// 创建模板请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTemplateRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
@@ -533,6 +790,466 @@ pub struct ProjectContext {
|
||||
pub template: Option<Template>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 品牌人设扩展类型
|
||||
// ============================================================================
|
||||
|
||||
/// 品牌个性类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BrandPersonality {
|
||||
/// 专业严谨
|
||||
Professional,
|
||||
/// 亲切友好
|
||||
Friendly,
|
||||
/// 活泼有趣
|
||||
Playful,
|
||||
/// 奢华高端
|
||||
Luxurious,
|
||||
/// 简约克制
|
||||
Minimalist,
|
||||
/// 大胆张扬
|
||||
Bold,
|
||||
/// 优雅精致
|
||||
Elegant,
|
||||
}
|
||||
|
||||
impl Default for BrandPersonality {
|
||||
fn default() -> Self {
|
||||
Self::Professional
|
||||
}
|
||||
}
|
||||
|
||||
impl BrandPersonality {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
BrandPersonality::Professional => "professional",
|
||||
BrandPersonality::Friendly => "friendly",
|
||||
BrandPersonality::Playful => "playful",
|
||||
BrandPersonality::Luxurious => "luxurious",
|
||||
BrandPersonality::Minimalist => "minimalist",
|
||||
BrandPersonality::Bold => "bold",
|
||||
BrandPersonality::Elegant => "elegant",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"professional" => BrandPersonality::Professional,
|
||||
"friendly" => BrandPersonality::Friendly,
|
||||
"playful" => BrandPersonality::Playful,
|
||||
"luxurious" => BrandPersonality::Luxurious,
|
||||
"minimalist" => BrandPersonality::Minimalist,
|
||||
"bold" => BrandPersonality::Bold,
|
||||
"elegant" => BrandPersonality::Elegant,
|
||||
_ => BrandPersonality::Professional,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
BrandPersonality::Professional => "专业严谨",
|
||||
BrandPersonality::Friendly => "亲切友好",
|
||||
BrandPersonality::Playful => "活泼有趣",
|
||||
BrandPersonality::Luxurious => "奢华高端",
|
||||
BrandPersonality::Minimalist => "简约克制",
|
||||
BrandPersonality::Bold => "大胆张扬",
|
||||
BrandPersonality::Elegant => "优雅精致",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设计风格类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DesignStyle {
|
||||
/// 极简
|
||||
Minimal,
|
||||
/// 现代
|
||||
Modern,
|
||||
/// 经典
|
||||
Classic,
|
||||
/// 活泼
|
||||
Playful,
|
||||
/// 商务
|
||||
Corporate,
|
||||
/// 艺术
|
||||
Artistic,
|
||||
/// 复古
|
||||
Retro,
|
||||
}
|
||||
|
||||
impl Default for DesignStyle {
|
||||
fn default() -> Self {
|
||||
Self::Modern
|
||||
}
|
||||
}
|
||||
|
||||
impl DesignStyle {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DesignStyle::Minimal => "minimal",
|
||||
DesignStyle::Modern => "modern",
|
||||
DesignStyle::Classic => "classic",
|
||||
DesignStyle::Playful => "playful",
|
||||
DesignStyle::Corporate => "corporate",
|
||||
DesignStyle::Artistic => "artistic",
|
||||
DesignStyle::Retro => "retro",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"minimal" => DesignStyle::Minimal,
|
||||
"modern" => DesignStyle::Modern,
|
||||
"classic" => DesignStyle::Classic,
|
||||
"playful" => DesignStyle::Playful,
|
||||
"corporate" => DesignStyle::Corporate,
|
||||
"artistic" => DesignStyle::Artistic,
|
||||
"retro" => DesignStyle::Retro,
|
||||
_ => DesignStyle::Modern,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
DesignStyle::Minimal => "极简",
|
||||
DesignStyle::Modern => "现代",
|
||||
DesignStyle::Classic => "经典",
|
||||
DesignStyle::Playful => "活泼",
|
||||
DesignStyle::Corporate => "商务",
|
||||
DesignStyle::Artistic => "艺术",
|
||||
DesignStyle::Retro => "复古",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配色方案
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorScheme {
|
||||
/// 主色
|
||||
pub primary: String,
|
||||
/// 辅色
|
||||
pub secondary: String,
|
||||
/// 强调色
|
||||
pub accent: String,
|
||||
/// 背景色
|
||||
pub background: String,
|
||||
/// 文字色
|
||||
pub text: String,
|
||||
/// 次要文字色
|
||||
pub text_secondary: String,
|
||||
/// 渐变配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gradients: Option<Vec<GradientConfig>>,
|
||||
}
|
||||
|
||||
impl Default for ColorScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
primary: "#2196F3".to_string(),
|
||||
secondary: "#90CAF9".to_string(),
|
||||
accent: "#1976D2".to_string(),
|
||||
background: "#FFFFFF".to_string(),
|
||||
text: "#212121".to_string(),
|
||||
text_secondary: "#757575".to_string(),
|
||||
gradients: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 渐变配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GradientConfig {
|
||||
/// 渐变名称
|
||||
pub name: String,
|
||||
/// 渐变颜色列表
|
||||
pub colors: Vec<String>,
|
||||
/// 渐变方向(角度)
|
||||
pub direction: i32,
|
||||
}
|
||||
|
||||
/// 字体方案
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Typography {
|
||||
/// 标题字体
|
||||
pub title_font: String,
|
||||
/// 标题字重
|
||||
pub title_weight: i32,
|
||||
/// 正文字体
|
||||
pub body_font: String,
|
||||
/// 正文字重
|
||||
pub body_weight: i32,
|
||||
/// 标题字号基准
|
||||
pub title_size: i32,
|
||||
/// 正文字号基准
|
||||
pub body_size: i32,
|
||||
/// 行高
|
||||
pub line_height: f32,
|
||||
/// 字间距
|
||||
pub letter_spacing: f32,
|
||||
}
|
||||
|
||||
impl Default for Typography {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title_font: "思源黑体".to_string(),
|
||||
title_weight: 700,
|
||||
body_font: "苹方".to_string(),
|
||||
body_weight: 400,
|
||||
title_size: 72,
|
||||
body_size: 24,
|
||||
line_height: 1.5,
|
||||
letter_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logo 位置配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoPlacement {
|
||||
/// 默认位置
|
||||
pub default_position: String,
|
||||
/// 内边距
|
||||
pub padding: i32,
|
||||
/// 最大尺寸(百分比)
|
||||
pub max_size: i32,
|
||||
}
|
||||
|
||||
impl Default for LogoPlacement {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_position: "top-left".to_string(),
|
||||
padding: 20,
|
||||
max_size: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片风格配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImageStyle {
|
||||
/// CSS 滤镜
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filter: Option<String>,
|
||||
/// 圆角
|
||||
pub border_radius: i32,
|
||||
/// 阴影
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<String>,
|
||||
/// 偏好比例
|
||||
pub preferred_ratio: String,
|
||||
}
|
||||
|
||||
impl Default for ImageStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter: None,
|
||||
border_radius: 8,
|
||||
shadow: None,
|
||||
preferred_ratio: "3:4".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标风格配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IconStyle {
|
||||
/// 风格类型
|
||||
pub style: String,
|
||||
/// 描边宽度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_width: Option<i32>,
|
||||
/// 默认颜色
|
||||
pub default_color: String,
|
||||
}
|
||||
|
||||
impl Default for IconStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
style: "outlined".to_string(),
|
||||
stroke_width: Some(2),
|
||||
default_color: "#333333".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 品牌调性配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrandTone {
|
||||
/// 品牌关键词
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
/// 品牌个性
|
||||
pub personality: String,
|
||||
/// 品牌语调
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_tone: Option<String>,
|
||||
/// 目标受众描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_audience: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BrandTone {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keywords: vec![],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设计配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesignConfig {
|
||||
/// 主风格
|
||||
pub primary_style: String,
|
||||
/// 配色方案
|
||||
pub color_scheme: ColorScheme,
|
||||
/// 字体方案
|
||||
pub typography: Typography,
|
||||
}
|
||||
|
||||
impl Default for DesignConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
primary_style: "modern".to_string(),
|
||||
color_scheme: ColorScheme::default(),
|
||||
typography: Typography::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 视觉规范配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VisualConfig {
|
||||
/// Logo 图片 URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logo_url: Option<String>,
|
||||
/// Logo 位置配置
|
||||
pub logo_placement: LogoPlacement,
|
||||
/// 图片风格
|
||||
pub image_style: ImageStyle,
|
||||
/// 图标风格
|
||||
pub icon_style: IconStyle,
|
||||
/// 装饰元素列表
|
||||
#[serde(default)]
|
||||
pub decorations: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for VisualConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
logo_url: None,
|
||||
logo_placement: LogoPlacement::default(),
|
||||
image_style: ImageStyle::default(),
|
||||
icon_style: IconStyle::default(),
|
||||
decorations: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 品牌人设扩展
|
||||
///
|
||||
/// 存储品牌人设的海报设计专用字段,与基础 Persona 关联。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrandPersonaExtension {
|
||||
/// 关联的人设 ID
|
||||
pub persona_id: String,
|
||||
/// 品牌调性
|
||||
pub brand_tone: BrandTone,
|
||||
/// 设计配置
|
||||
pub design: DesignConfig,
|
||||
/// 视觉规范
|
||||
pub visual: VisualConfig,
|
||||
/// 创建时间
|
||||
pub created_at: i64,
|
||||
/// 更新时间
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 创建品牌人设扩展请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBrandExtensionRequest {
|
||||
/// 关联的人设 ID
|
||||
pub persona_id: String,
|
||||
/// 品牌调性
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_tone: Option<BrandTone>,
|
||||
/// 设计配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub design: Option<DesignConfig>,
|
||||
/// 视觉规范
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual: Option<VisualConfig>,
|
||||
}
|
||||
|
||||
/// 更新品牌人设扩展请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateBrandExtensionRequest {
|
||||
/// 品牌调性
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_tone: Option<BrandTone>,
|
||||
/// 设计配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub design: Option<DesignConfig>,
|
||||
/// 视觉规范
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual: Option<VisualConfig>,
|
||||
}
|
||||
|
||||
/// 品牌人设(完整视图)
|
||||
///
|
||||
/// 包含基础人设和品牌扩展的完整数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrandPersona {
|
||||
/// 基础人设
|
||||
#[serde(flatten)]
|
||||
pub base: Persona,
|
||||
/// 品牌调性
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brand_tone: Option<BrandTone>,
|
||||
/// 设计配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub design: Option<DesignConfig>,
|
||||
/// 视觉规范
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual: Option<VisualConfig>,
|
||||
}
|
||||
|
||||
/// 品牌人设模板
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrandPersonaTemplate {
|
||||
/// 模板 ID
|
||||
pub id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 模板描述
|
||||
pub description: String,
|
||||
/// 品牌调性
|
||||
pub brand_tone: BrandTone,
|
||||
/// 设计配置
|
||||
pub design: DesignConfig,
|
||||
/// 视觉规范
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visual: Option<VisualConfig>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
@@ -548,12 +1265,67 @@ mod tests {
|
||||
assert_eq!(MaterialType::Text.as_str(), "text");
|
||||
assert_eq!(MaterialType::Data.as_str(), "data");
|
||||
assert_eq!(MaterialType::Link.as_str(), "link");
|
||||
assert_eq!(MaterialType::Icon.as_str(), "icon");
|
||||
assert_eq!(MaterialType::Color.as_str(), "color");
|
||||
assert_eq!(MaterialType::Layout.as_str(), "layout");
|
||||
|
||||
assert_eq!(MaterialType::from_str("document"), MaterialType::Document);
|
||||
assert_eq!(MaterialType::from_str("IMAGE"), MaterialType::Image);
|
||||
assert_eq!(MaterialType::from_str("icon"), MaterialType::Icon);
|
||||
assert_eq!(MaterialType::from_str("color"), MaterialType::Color);
|
||||
assert_eq!(MaterialType::from_str("layout"), MaterialType::Layout);
|
||||
assert_eq!(MaterialType::from_str("unknown"), MaterialType::Document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_type_is_poster_material() {
|
||||
assert!(MaterialType::Image.is_poster_material());
|
||||
assert!(MaterialType::Icon.is_poster_material());
|
||||
assert!(MaterialType::Color.is_poster_material());
|
||||
assert!(MaterialType::Layout.is_poster_material());
|
||||
assert!(!MaterialType::Document.is_poster_material());
|
||||
assert!(!MaterialType::Text.is_poster_material());
|
||||
assert!(!MaterialType::Data.is_poster_material());
|
||||
assert!(!MaterialType::Link.is_poster_material());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_category_conversion() {
|
||||
assert_eq!(ImageCategory::Background.as_str(), "background");
|
||||
assert_eq!(ImageCategory::Product.as_str(), "product");
|
||||
assert_eq!(ImageCategory::Person.as_str(), "person");
|
||||
|
||||
assert_eq!(
|
||||
ImageCategory::from_str("background"),
|
||||
ImageCategory::Background
|
||||
);
|
||||
assert_eq!(ImageCategory::from_str("PRODUCT"), ImageCategory::Product);
|
||||
assert_eq!(ImageCategory::from_str("unknown"), ImageCategory::Other);
|
||||
|
||||
assert_eq!(ImageCategory::Background.display_name(), "背景");
|
||||
assert_eq!(ImageCategory::Product.display_name(), "产品");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_category_conversion() {
|
||||
assert_eq!(LayoutCategory::HeroImage.as_str(), "hero-image");
|
||||
assert_eq!(LayoutCategory::TextDominant.as_str(), "text-dominant");
|
||||
assert_eq!(LayoutCategory::Grid.as_str(), "grid");
|
||||
|
||||
assert_eq!(
|
||||
LayoutCategory::from_str("hero-image"),
|
||||
LayoutCategory::HeroImage
|
||||
);
|
||||
assert_eq!(LayoutCategory::from_str("grid"), LayoutCategory::Grid);
|
||||
assert_eq!(
|
||||
LayoutCategory::from_str("unknown"),
|
||||
LayoutCategory::HeroImage
|
||||
);
|
||||
|
||||
assert_eq!(LayoutCategory::HeroImage.display_name(), "大图型");
|
||||
assert_eq!(LayoutCategory::Grid.display_name(), "网格型");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_conversion() {
|
||||
assert_eq!(Platform::Xiaohongshu.as_str(), "xiaohongshu");
|
||||
@@ -703,4 +1475,95 @@ mod tests {
|
||||
assert_eq!(Platform::default(), Platform::Markdown);
|
||||
assert_eq!(EmojiUsage::default(), EmojiUsage::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brand_personality_conversion() {
|
||||
assert_eq!(BrandPersonality::Professional.as_str(), "professional");
|
||||
assert_eq!(BrandPersonality::Friendly.as_str(), "friendly");
|
||||
assert_eq!(BrandPersonality::Playful.as_str(), "playful");
|
||||
|
||||
assert_eq!(
|
||||
BrandPersonality::from_str("professional"),
|
||||
BrandPersonality::Professional
|
||||
);
|
||||
assert_eq!(
|
||||
BrandPersonality::from_str("FRIENDLY"),
|
||||
BrandPersonality::Friendly
|
||||
);
|
||||
assert_eq!(
|
||||
BrandPersonality::from_str("unknown"),
|
||||
BrandPersonality::Professional
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brand_personality_display_name() {
|
||||
assert_eq!(BrandPersonality::Professional.display_name(), "专业严谨");
|
||||
assert_eq!(BrandPersonality::Friendly.display_name(), "亲切友好");
|
||||
assert_eq!(BrandPersonality::Luxurious.display_name(), "奢华高端");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_design_style_conversion() {
|
||||
assert_eq!(DesignStyle::Minimal.as_str(), "minimal");
|
||||
assert_eq!(DesignStyle::Modern.as_str(), "modern");
|
||||
assert_eq!(DesignStyle::Corporate.as_str(), "corporate");
|
||||
|
||||
assert_eq!(DesignStyle::from_str("minimal"), DesignStyle::Minimal);
|
||||
assert_eq!(DesignStyle::from_str("MODERN"), DesignStyle::Modern);
|
||||
assert_eq!(DesignStyle::from_str("unknown"), DesignStyle::Modern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_scheme_serialization() {
|
||||
let scheme = ColorScheme::default();
|
||||
let json = serde_json::to_string(&scheme).unwrap();
|
||||
let parsed: ColorScheme = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.primary, "#2196F3");
|
||||
assert_eq!(parsed.background, "#FFFFFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typography_serialization() {
|
||||
let typography = Typography::default();
|
||||
let json = serde_json::to_string(&typography).unwrap();
|
||||
let parsed: Typography = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.title_font, "思源黑体");
|
||||
assert_eq!(parsed.title_weight, 700);
|
||||
assert_eq!(parsed.body_font, "苹方");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brand_persona_extension_serialization() {
|
||||
let extension = BrandPersonaExtension {
|
||||
persona_id: "persona-1".to_string(),
|
||||
brand_tone: BrandTone::default(),
|
||||
design: DesignConfig::default(),
|
||||
visual: VisualConfig::default(),
|
||||
created_at: 1234567890,
|
||||
updated_at: 1234567890,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&extension).unwrap();
|
||||
let parsed: BrandPersonaExtension = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.persona_id, "persona-1");
|
||||
assert_eq!(parsed.brand_tone.personality, "professional");
|
||||
assert_eq!(parsed.design.primary_style, "modern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brand_defaults() {
|
||||
assert_eq!(BrandPersonality::default(), BrandPersonality::Professional);
|
||||
assert_eq!(DesignStyle::default(), DesignStyle::Modern);
|
||||
|
||||
let color_scheme = ColorScheme::default();
|
||||
assert_eq!(color_scheme.primary, "#2196F3");
|
||||
|
||||
let typography = Typography::default();
|
||||
assert_eq!(typography.title_size, 72);
|
||||
assert_eq!(typography.body_size, 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! - 创建、获取、列表、更新、删除人设
|
||||
//! - 设置项目默认人设
|
||||
//! - 获取人设模板列表
|
||||
//! - 品牌人设扩展管理
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 6.1: 人设列表显示
|
||||
@@ -15,9 +16,13 @@
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::database::dao::brand_persona_dao::BrandPersonaDao;
|
||||
use crate::database::dao::persona_dao::PersonaDao;
|
||||
use crate::errors::project_error::PersonaError;
|
||||
use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate};
|
||||
use crate::models::project_model::{
|
||||
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest,
|
||||
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 人设服务
|
||||
@@ -278,6 +283,115 @@ impl PersonaService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 品牌人设扩展
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取品牌人设(基础人设 + 扩展)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_brand_persona(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<BrandPersona>, PersonaError> {
|
||||
BrandPersonaDao::get_brand_persona(conn, persona_id)
|
||||
}
|
||||
|
||||
/// 获取品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<BrandPersonaExtension>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_brand_extension(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<BrandPersonaExtension>, PersonaError> {
|
||||
BrandPersonaDao::get(conn, persona_id)
|
||||
}
|
||||
|
||||
/// 保存品牌人设扩展
|
||||
///
|
||||
/// 如果扩展不存在则创建,存在则更新。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建/更新请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回保存后的扩展
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn save_brand_extension(
|
||||
conn: &Connection,
|
||||
req: CreateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, PersonaError> {
|
||||
// 检查是否已存在
|
||||
let existing = BrandPersonaDao::get(conn, &req.persona_id)?;
|
||||
|
||||
if existing.is_some() {
|
||||
// 更新
|
||||
let update = UpdateBrandExtensionRequest {
|
||||
brand_tone: req.brand_tone,
|
||||
design: req.design,
|
||||
visual: req.visual,
|
||||
};
|
||||
BrandPersonaDao::update(conn, &req.persona_id, &update)
|
||||
} else {
|
||||
// 创建
|
||||
BrandPersonaDao::create(conn, &req)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的扩展
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn update_brand_extension(
|
||||
conn: &Connection,
|
||||
persona_id: &str,
|
||||
update: UpdateBrandExtensionRequest,
|
||||
) -> Result<BrandPersonaExtension, PersonaError> {
|
||||
BrandPersonaDao::update(conn, persona_id, &update)
|
||||
}
|
||||
|
||||
/// 删除品牌人设扩展
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `persona_id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn delete_brand_extension(conn: &Connection, persona_id: &str) -> Result<(), PersonaError> {
|
||||
BrandPersonaDao::delete(conn, persona_id)
|
||||
}
|
||||
|
||||
/// 获取品牌人设模板列表
|
||||
///
|
||||
/// 返回预定义的品牌人设模板,用于快速创建品牌人设。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 品牌人设模板列表
|
||||
pub fn list_brand_persona_templates() -> Vec<BrandPersonaTemplate> {
|
||||
BrandPersonaDao::list_templates()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -572,4 +686,175 @@ mod tests {
|
||||
let default = PersonaService::get_default_persona(&conn, "project-1").unwrap();
|
||||
assert!(default.is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 品牌人设扩展测试
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_get_brand_persona() {
|
||||
use crate::models::project_model::{BrandTone, DesignConfig};
|
||||
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建基础人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "品牌人设".to_string(),
|
||||
description: None,
|
||||
style: "专业".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
// 保存品牌扩展
|
||||
let brand_req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["专业".to_string(), "可信赖".to_string()],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: Some("专业但不冷漠".to_string()),
|
||||
target_audience: Some("技术人员".to_string()),
|
||||
}),
|
||||
design: Some(DesignConfig::default()),
|
||||
visual: None,
|
||||
};
|
||||
PersonaService::save_brand_extension(&conn, brand_req).unwrap();
|
||||
|
||||
// 获取完整品牌人设
|
||||
let brand_persona = PersonaService::get_brand_persona(&conn, &persona.id).unwrap();
|
||||
assert!(brand_persona.is_some());
|
||||
let brand_persona = brand_persona.unwrap();
|
||||
|
||||
assert_eq!(brand_persona.base.id, persona.id);
|
||||
assert!(brand_persona.brand_tone.is_some());
|
||||
assert_eq!(
|
||||
brand_persona.brand_tone.unwrap().personality,
|
||||
"professional"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_brand_extension_creates_new() {
|
||||
use crate::models::project_model::BrandTone;
|
||||
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建基础人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
// 保存品牌扩展(新建)
|
||||
let brand_req = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["测试".to_string()],
|
||||
personality: "friendly".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
let extension = PersonaService::save_brand_extension(&conn, brand_req).unwrap();
|
||||
|
||||
assert_eq!(extension.persona_id, persona.id);
|
||||
assert_eq!(extension.brand_tone.personality, "friendly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_brand_extension_updates_existing() {
|
||||
use crate::models::project_model::BrandTone;
|
||||
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建基础人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
// 第一次保存
|
||||
let brand_req1 = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["原始".to_string()],
|
||||
personality: "professional".to_string(),
|
||||
voice_tone: None,
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
PersonaService::save_brand_extension(&conn, brand_req1).unwrap();
|
||||
|
||||
// 第二次保存(更新)
|
||||
let brand_req2 = CreateBrandExtensionRequest {
|
||||
persona_id: persona.id.clone(),
|
||||
brand_tone: Some(BrandTone {
|
||||
keywords: vec!["更新".to_string()],
|
||||
personality: "bold".to_string(),
|
||||
voice_tone: Some("大胆".to_string()),
|
||||
target_audience: None,
|
||||
}),
|
||||
design: None,
|
||||
visual: None,
|
||||
};
|
||||
let extension = PersonaService::save_brand_extension(&conn, brand_req2).unwrap();
|
||||
|
||||
assert_eq!(extension.brand_tone.keywords, vec!["更新".to_string()]);
|
||||
assert_eq!(extension.brand_tone.personality, "bold");
|
||||
assert_eq!(extension.brand_tone.voice_tone, Some("大胆".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_brand_persona_templates() {
|
||||
let templates = PersonaService::list_brand_persona_templates();
|
||||
|
||||
// 验证模板数量
|
||||
assert_eq!(templates.len(), 4);
|
||||
|
||||
// 验证模板 ID
|
||||
let template_ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(template_ids.contains(&"ecommerce-promo"));
|
||||
assert!(template_ids.contains(&"brand-image"));
|
||||
assert!(template_ids.contains(&"social-media"));
|
||||
assert!(template_ids.contains(&"event-promo"));
|
||||
|
||||
// 验证模板内容
|
||||
let ecommerce = templates
|
||||
.iter()
|
||||
.find(|t| t.id == "ecommerce-promo")
|
||||
.unwrap();
|
||||
assert_eq!(ecommerce.name, "电商促销");
|
||||
assert_eq!(ecommerce.brand_tone.personality, "bold");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,12 @@
|
||||
//! let text = AsrService::transcribe(&credential, &audio_data, 16000).await?;
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "local-whisper")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{load_config, AsrCredentialEntry, AsrProviderType, WhisperModelSize};
|
||||
#[cfg(feature = "local-whisper")]
|
||||
use crate::config::WhisperModelSize;
|
||||
use crate::config::{load_config, AsrCredentialEntry, AsrProviderType};
|
||||
|
||||
/// ASR 服务
|
||||
pub struct AsrService;
|
||||
@@ -133,6 +136,7 @@ impl AsrService {
|
||||
}
|
||||
|
||||
/// 本地 Whisper 识别
|
||||
#[cfg(feature = "local-whisper")]
|
||||
async fn transcribe_whisper_local(
|
||||
credential: &AsrCredentialEntry,
|
||||
audio_data: &[u8],
|
||||
@@ -182,7 +186,18 @@ impl AsrService {
|
||||
Ok(result.text)
|
||||
}
|
||||
|
||||
/// 本地 Whisper 识别(未启用 local-whisper feature 时的 stub)
|
||||
#[cfg(not(feature = "local-whisper"))]
|
||||
async fn transcribe_whisper_local(
|
||||
_credential: &AsrCredentialEntry,
|
||||
_audio_data: &[u8],
|
||||
_sample_rate: u32,
|
||||
) -> Result<String, String> {
|
||||
Err("本地 Whisper 功能未启用。请使用云端 ASR 服务(OpenAI、百度、讯飞)".to_string())
|
||||
}
|
||||
|
||||
/// 获取 Whisper 模型文件路径
|
||||
#[cfg(feature = "local-whisper")]
|
||||
fn get_whisper_model_path(model_size: &WhisperModelSize) -> Result<PathBuf, String> {
|
||||
// 模型文件名
|
||||
let filename = match model_size {
|
||||
@@ -214,6 +229,7 @@ impl AsrService {
|
||||
}
|
||||
|
||||
/// 转换模型大小枚举
|
||||
#[cfg(feature = "local-whisper")]
|
||||
fn convert_model_size(size: &WhisperModelSize) -> voice_core::types::WhisperModel {
|
||||
match size {
|
||||
WhisperModelSize::Tiny => voice_core::types::WhisperModel::Tiny,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.53.0",
|
||||
"version": "0.57.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -28,6 +28,10 @@ interface MessageListProps {
|
||||
onEditMessage?: (id: string, content: string) => void;
|
||||
/** A2UI 表单提交回调 */
|
||||
onA2UISubmit?: (formData: A2UIFormData, messageId: string) => void;
|
||||
/** A2UI 表单数据映射(按消息 ID 索引) */
|
||||
a2uiFormDataMap?: Record<string, { formId: string; formData: A2UIFormData }>;
|
||||
/** A2UI 表单数据变化回调(用于持久化) */
|
||||
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
/** 文件写入回调 */
|
||||
onWriteFile?: (content: string, fileName: string) => void;
|
||||
/** 文件点击回调 */
|
||||
@@ -45,6 +49,8 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
onDeleteMessage,
|
||||
onEditMessage,
|
||||
onA2UISubmit,
|
||||
a2uiFormDataMap,
|
||||
onA2UIFormChange,
|
||||
onWriteFile,
|
||||
onFileClick,
|
||||
onPermissionResponse,
|
||||
@@ -219,6 +225,9 @@ export const MessageList: React.FC<MessageListProps> = ({
|
||||
? (formData) => onA2UISubmit(formData, msg.id)
|
||||
: undefined
|
||||
}
|
||||
a2uiFormId={a2uiFormDataMap?.[msg.id]?.formId}
|
||||
a2uiInitialFormData={a2uiFormDataMap?.[msg.id]?.formData}
|
||||
onA2UIFormChange={onA2UIFormChange}
|
||||
onWriteFile={onWriteFile}
|
||||
onFileClick={onFileClick}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
|
||||
@@ -332,6 +332,12 @@ interface StreamingRendererProps {
|
||||
actionRequests?: ActionRequired[];
|
||||
/** A2UI 表单提交回调 */
|
||||
onA2UISubmit?: (formData: A2UIFormData) => void;
|
||||
/** A2UI 表单 ID(用于持久化) */
|
||||
a2uiFormId?: string;
|
||||
/** A2UI 初始表单数据(从数据库加载) */
|
||||
a2uiInitialFormData?: A2UIFormData;
|
||||
/** A2UI 表单数据变化回调(用于持久化) */
|
||||
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
/** 文件写入回调 */
|
||||
onWriteFile?: (content: string, fileName: string) => void;
|
||||
/** 文件点击回调 */
|
||||
@@ -364,6 +370,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
contentParts,
|
||||
actionRequests,
|
||||
onA2UISubmit,
|
||||
a2uiFormId,
|
||||
a2uiInitialFormData,
|
||||
onA2UIFormChange,
|
||||
onWriteFile,
|
||||
onFileClick,
|
||||
onPermissionResponse,
|
||||
@@ -624,6 +633,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
key={`a2ui-${index}`}
|
||||
response={part.content}
|
||||
onSubmit={onA2UISubmit}
|
||||
formId={a2uiFormId}
|
||||
initialFormData={a2uiInitialFormData}
|
||||
onFormChange={onA2UIFormChange}
|
||||
className="my-3"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type SessionInfo,
|
||||
type StreamEvent,
|
||||
} from "@/lib/api/agent";
|
||||
import { A2UIFormAPI } from "@/lib/api/a2uiForm";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import {
|
||||
Message,
|
||||
MessageImage,
|
||||
@@ -184,6 +186,11 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
// 话题列表
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
|
||||
// A2UI 表单数据缓存(按消息 ID 索引)
|
||||
const [a2uiFormDataMap, setA2uiFormDataMap] = useState<
|
||||
Record<string, { formId: string; formData: A2UIFormData }>
|
||||
>({});
|
||||
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// 当前会话的轮数计数器(用于判断何时生成标题)
|
||||
@@ -959,12 +966,30 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
// 从后端加载消息历史
|
||||
const agentMessages = await getAgentSessionMessages(topicId);
|
||||
console.log("[useAgentChat] 加载到消息数量:", agentMessages.length);
|
||||
console.log(
|
||||
"[useAgentChat] 原始消息:",
|
||||
JSON.stringify(agentMessages.slice(0, 2), null, 2),
|
||||
);
|
||||
|
||||
// 加载 A2UI 表单数据
|
||||
let formDataMap: Record<
|
||||
string,
|
||||
{ formId: string; formData: A2UIFormData }
|
||||
> = {};
|
||||
try {
|
||||
const forms = await A2UIFormAPI.getBySession(topicId);
|
||||
console.log("[useAgentChat] 加载到 A2UI 表单数量:", forms.length);
|
||||
for (const form of forms) {
|
||||
const msgId = `${topicId}-${form.messageId}`;
|
||||
formDataMap[msgId] = {
|
||||
formId: form.id,
|
||||
formData: form.formDataJson ? JSON.parse(form.formDataJson) : {},
|
||||
};
|
||||
}
|
||||
} catch (formError) {
|
||||
console.warn("[useAgentChat] 加载 A2UI 表单数据失败:", formError);
|
||||
}
|
||||
setA2uiFormDataMap(formDataMap);
|
||||
|
||||
// 转换为前端 Message 格式
|
||||
// 注意:不设置 contentParts,让 StreamingRenderer 使用回退模式
|
||||
// 回退模式会直接解析 content 中的 A2UI 代码块
|
||||
const loadedMessages: Message[] = agentMessages.map((msg, index) => {
|
||||
// 提取文本内容
|
||||
let content = "";
|
||||
@@ -980,9 +1005,12 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[useAgentChat] 消息 ${index}: role=${msg.role}, content类型=${typeof msg.content}, 内容长度=${content.length}`,
|
||||
);
|
||||
// 检查是否包含 A2UI 内容(用于调试)
|
||||
if (msg.role === "assistant" && content.includes("```a2ui")) {
|
||||
console.log(
|
||||
`[useAgentChat] 消息 ${index} 包含 A2UI 代码块,将由 StreamingRenderer 解析`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${topicId}-${index}`,
|
||||
@@ -990,6 +1018,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
isThinking: false,
|
||||
// 不设置 contentParts,让 StreamingRenderer 使用回退模式解析 A2UI
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1475,6 +1504,44 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// A2UI 表单数据保存(防抖由 A2UIRenderer 处理)
|
||||
const saveA2UIFormData = useCallback(
|
||||
async (formId: string, formData: A2UIFormData) => {
|
||||
try {
|
||||
await A2UIFormAPI.saveFormData(formId, JSON.stringify(formData));
|
||||
console.log("[useAgentChat] A2UI 表单数据已保存:", formId);
|
||||
} catch (error) {
|
||||
console.error("[useAgentChat] 保存 A2UI 表单数据失败:", error);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// A2UI 表单提交处理
|
||||
const handleA2UISubmit = useCallback(
|
||||
async (formData: A2UIFormData, messageId: string) => {
|
||||
console.log("[useAgentChat] A2UI 表单提交:", messageId, formData);
|
||||
|
||||
// 获取或创建表单记录
|
||||
const existingForm = a2uiFormDataMap[messageId];
|
||||
if (existingForm) {
|
||||
try {
|
||||
await A2UIFormAPI.submit(
|
||||
existingForm.formId,
|
||||
JSON.stringify(formData),
|
||||
);
|
||||
toast.success("表单已提交");
|
||||
} catch (error) {
|
||||
console.error("[useAgentChat] 提交 A2UI 表单失败:", error);
|
||||
toast.error("表单提交失败");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 可以在这里触发后续的 AI 处理流程
|
||||
},
|
||||
[a2uiFormDataMap],
|
||||
);
|
||||
|
||||
return {
|
||||
processStatus,
|
||||
handleStartProcess,
|
||||
@@ -1507,5 +1574,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
loadTopics,
|
||||
renameTopic, // 重命名话题
|
||||
generateSmartTitle, // 智能标题生成
|
||||
|
||||
// A2UI 表单持久化
|
||||
a2uiFormDataMap,
|
||||
saveA2UIFormData,
|
||||
handleA2UISubmit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# A2UI - Agent-to-User Interface
|
||||
|
||||
结构化 UI 响应系统,参考 Google A2UI 规范实现。
|
||||
结构化 UI 响应系统,基于 Google A2UI v0.10 规范实现。
|
||||
|
||||
## 功能概述
|
||||
|
||||
@@ -11,10 +11,14 @@ A2UI 允许 AI 返回结构化的表单组件,用户通过点击选项来回
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `types.ts` | A2UI 组件类型定义 |
|
||||
| `types.ts` | A2UI 组件类型定义(基于 v0.10 规范) |
|
||||
| `parser.ts` | A2UI JSON 解析器,支持简化表单格式 |
|
||||
| `components/` | React 组件渲染器 |
|
||||
|
||||
## 规范版本
|
||||
|
||||
当前实现基于 [A2UI v0.10 规范](https://a2ui.org/specification/v0_10/)。
|
||||
|
||||
## 支持的格式
|
||||
|
||||
### 1. 简化表单格式(推荐)
|
||||
@@ -48,9 +52,34 @@ AI 返回简单的 JSON 格式,系统自动转换为完整 A2UI:
|
||||
|
||||
## 支持的组件
|
||||
|
||||
- **布局**: Row, Column, Card, Divider
|
||||
- **展示**: Text, Icon, Image
|
||||
- **交互**: Button, TextField, CheckBox, ChoicePicker, Slider, DateTimeInput
|
||||
### 布局组件
|
||||
- Row, Column, List, Card, Tabs, Modal, Divider
|
||||
|
||||
### 展示组件
|
||||
- Text, Icon, Image, Video, AudioPlayer
|
||||
|
||||
### 交互组件
|
||||
- Button, TextField, CheckBox, ChoicePicker, Slider, DateTimeInput
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 动态值
|
||||
支持字面量、数据绑定和函数调用:
|
||||
- 字面量: `"Hello"`
|
||||
- 数据绑定: `{ "path": "/user/name" }`
|
||||
- 函数调用: `{ "call": "formatDate", "args": { "value": { "path": "/date" } } }`
|
||||
|
||||
### 验证规则 (checks)
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"condition": { "call": "required", "args": { "value": { "path": "/email" } } },
|
||||
"message": "邮箱不能为空"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
@@ -65,6 +94,10 @@ AI 响应中使用 `\`\`\`a2ui` 代码块包裹 JSON:
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
## 相关项目
|
||||
|
||||
- `aster-a2ui`: Rust 实现的 A2UI 协议库(位于 aster-rust 框架)
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- 被 `StreamingRenderer` 组件使用
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file 组件渲染器
|
||||
* @description 根据组件类型分发到对应的渲染器
|
||||
*/
|
||||
|
||||
import type { A2UIComponent, A2UIFormData, A2UIEvent } from "../types";
|
||||
|
||||
// 布局组件
|
||||
import { RowRenderer } from "./layout/Row";
|
||||
import { ColumnRenderer } from "./layout/Column";
|
||||
import { CardRenderer } from "./layout/Card";
|
||||
import { DividerRenderer } from "./layout/Divider";
|
||||
|
||||
// 展示组件
|
||||
import { TextRenderer } from "./display/Text";
|
||||
import { ButtonRenderer } from "./display/Button";
|
||||
|
||||
// 表单组件
|
||||
import { TextFieldRenderer } from "./form/TextField";
|
||||
import { CheckBoxRenderer } from "./form/CheckBox";
|
||||
import { ChoicePickerRenderer } from "./form/ChoicePicker";
|
||||
import { SliderRenderer } from "./form/Slider";
|
||||
|
||||
export interface ComponentRendererProps {
|
||||
component: A2UIComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
export function ComponentRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps) {
|
||||
switch (component.component) {
|
||||
case "Row":
|
||||
return (
|
||||
<RowRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Column":
|
||||
return (
|
||||
<ColumnRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Card":
|
||||
return (
|
||||
<CardRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Divider":
|
||||
return <DividerRenderer component={component} />;
|
||||
case "Text":
|
||||
return <TextRenderer component={component} data={data} />;
|
||||
case "Button":
|
||||
return (
|
||||
<ButtonRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "TextField":
|
||||
return (
|
||||
<TextFieldRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "CheckBox":
|
||||
return (
|
||||
<CheckBoxRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "ChoicePicker":
|
||||
return (
|
||||
<ChoicePickerRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "Slider":
|
||||
return (
|
||||
<SliderRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="text-yellow-500">
|
||||
未知组件: {(component as A2UIComponent).component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# A2UI 组件
|
||||
|
||||
A2UI (Agent-to-User Interface) 组件渲染器,基于 Google A2UI v0.10 规范。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
components/
|
||||
├── index.tsx # 主渲染器 A2UIRenderer
|
||||
├── ComponentRenderer.tsx # 组件分发器
|
||||
├── layout/ # 布局组件
|
||||
│ ├── Row.tsx # 水平布局
|
||||
│ ├── Column.tsx # 垂直布局
|
||||
│ ├── Card.tsx # 卡片容器
|
||||
│ └── Divider.tsx # 分隔线
|
||||
├── display/ # 展示组件
|
||||
│ ├── Text.tsx # 文本
|
||||
│ └── Button.tsx # 按钮
|
||||
└── form/ # 表单组件
|
||||
├── TextField.tsx # 文本输入
|
||||
├── CheckBox.tsx # 复选框
|
||||
├── ChoicePicker.tsx # 选择器
|
||||
└── Slider.tsx # 滑块
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
```tsx
|
||||
import { A2UIRenderer } from "./components";
|
||||
|
||||
<A2UIRenderer
|
||||
response={a2uiResponse}
|
||||
onEvent={handleEvent}
|
||||
onSubmit={handleSubmit}
|
||||
formId="unique-form-id"
|
||||
initialFormData={savedFormData}
|
||||
onFormChange={handleFormChange}
|
||||
/>
|
||||
```
|
||||
|
||||
## 组件类型
|
||||
|
||||
| 类型 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| 布局 | Row, Column, Card, Divider | 容器和分隔 |
|
||||
| 展示 | Text, Button | 文本和交互 |
|
||||
| 表单 | TextField, CheckBox, ChoicePicker, Slider | 数据输入 |
|
||||
|
||||
## 扩展组件
|
||||
|
||||
如需添加新组件:
|
||||
1. 在对应目录创建组件文件
|
||||
2. 在 `ComponentRenderer.tsx` 添加 case
|
||||
3. 在 `index.tsx` 导出
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file Button 展示组件
|
||||
* @description 按钮
|
||||
*/
|
||||
|
||||
import type {
|
||||
ButtonComponent,
|
||||
TextComponent,
|
||||
A2UIComponent,
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById, resolveDynamicValue } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonRendererProps {
|
||||
component: ButtonComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
const variantClass: Record<string, string> = {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
borderless: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
export function ButtonRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
onAction,
|
||||
}: ButtonRendererProps) {
|
||||
const child = getComponentById(components, component.child);
|
||||
const label =
|
||||
child && child.component === "Text"
|
||||
? resolveDynamicValue((child as TextComponent).text, data, "")
|
||||
: "";
|
||||
|
||||
const handleClick = () => {
|
||||
// 兼容新旧 action 格式
|
||||
let actionName = "";
|
||||
let actionContext: Record<string, unknown> | undefined;
|
||||
|
||||
if ("event" in component.action) {
|
||||
// EventAction 格式
|
||||
actionName = component.action.event.name;
|
||||
actionContext = component.action.event.context as Record<string, unknown>;
|
||||
} else if ("functionCall" in component.action) {
|
||||
// FunctionAction 格式
|
||||
actionName = component.action.functionCall.call;
|
||||
actionContext = component.action.functionCall.args as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} else if ("name" in component.action) {
|
||||
// 旧的 ButtonAction 格式
|
||||
actionName = component.action.name;
|
||||
actionContext = component.action.context as Record<string, unknown>;
|
||||
}
|
||||
|
||||
onAction({
|
||||
type: "action",
|
||||
componentId: component.id,
|
||||
action: {
|
||||
name: actionName,
|
||||
context: actionContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md transition-colors",
|
||||
variantClass[component.variant || "primary"],
|
||||
)}
|
||||
>
|
||||
{String(label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file Text 展示组件
|
||||
* @description 文本显示
|
||||
*/
|
||||
|
||||
import type { TextComponent } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
|
||||
interface TextRendererProps {
|
||||
component: TextComponent;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const variantClass: Record<string, string> = {
|
||||
h1: "text-2xl font-bold",
|
||||
h2: "text-xl font-semibold",
|
||||
h3: "text-lg font-semibold",
|
||||
h4: "text-base font-medium",
|
||||
h5: "text-sm font-medium",
|
||||
body: "text-sm",
|
||||
caption: "text-xs text-muted-foreground",
|
||||
};
|
||||
|
||||
export function TextRenderer({ component, data }: TextRendererProps) {
|
||||
const text = resolveDynamicValue(component.text, data, "");
|
||||
|
||||
return (
|
||||
<div className={variantClass[component.variant || "body"]}>
|
||||
{String(text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file 展示组件导出
|
||||
*/
|
||||
|
||||
export { TextRenderer } from "./Text";
|
||||
export { ButtonRenderer } from "./Button";
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @file CheckBox 表单组件
|
||||
* @description 复选框
|
||||
*/
|
||||
|
||||
import type { CheckBoxComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
|
||||
interface CheckBoxRendererProps {
|
||||
component: CheckBoxComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function CheckBoxRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: CheckBoxRendererProps) {
|
||||
const label = String(resolveDynamicValue(component.label, data, ""));
|
||||
const checked =
|
||||
(formData[component.id] as boolean) ??
|
||||
Boolean(resolveDynamicValue(component.value, data, false));
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onFormChange(component.id, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @file ChoicePicker 表单组件
|
||||
* @description 选择器
|
||||
*/
|
||||
|
||||
import type { ChoicePickerComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChoicePickerRendererProps {
|
||||
component: ChoicePickerComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function ChoicePickerRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: ChoicePickerRendererProps) {
|
||||
const label = component.label
|
||||
? String(resolveDynamicValue(component.label, data, ""))
|
||||
: "";
|
||||
const selectedValues =
|
||||
(formData[component.id] as string[]) ??
|
||||
(resolveDynamicValue(component.value, data, []) as string[]);
|
||||
const isMultiple = component.variant === "multipleSelection";
|
||||
const isWrap =
|
||||
component.layout === "wrap" || component.layout === "horizontal";
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (isMultiple) {
|
||||
const newValues = selectedValues.includes(optionValue)
|
||||
? selectedValues.filter((v) => v !== optionValue)
|
||||
: [...selectedValues, optionValue];
|
||||
onFormChange(component.id, newValues);
|
||||
} else {
|
||||
onFormChange(component.id, [optionValue]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <div className="text-sm font-medium">{label}</div>}
|
||||
<div className={cn("flex gap-2", isWrap ? "flex-wrap" : "flex-col")}>
|
||||
{component.options.map((option) => {
|
||||
const optionLabel = String(
|
||||
resolveDynamicValue(option.label, data, ""),
|
||||
);
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm rounded-lg border transition-all text-left",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
<span>{optionLabel}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @file Slider 表单组件
|
||||
* @description 滑块
|
||||
*/
|
||||
|
||||
import type { SliderComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
|
||||
interface SliderRendererProps {
|
||||
component: SliderComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function SliderRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: SliderRendererProps) {
|
||||
const label = component.label
|
||||
? String(resolveDynamicValue(component.label, data, ""))
|
||||
: "";
|
||||
const value =
|
||||
(formData[component.id] as number) ??
|
||||
(resolveDynamicValue(component.value, data, component.min) as number);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{component.showValue !== false && (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={component.min}
|
||||
max={component.max}
|
||||
step={component.step || 1}
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
{component.marks && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
{component.marks.map((mark) => (
|
||||
<span key={mark.value}>{mark.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file TextField 表单组件
|
||||
* @description 文本输入框
|
||||
*/
|
||||
|
||||
import type { TextFieldComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
|
||||
interface TextFieldRendererProps {
|
||||
component: TextFieldComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function TextFieldRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: TextFieldRendererProps) {
|
||||
const label = String(resolveDynamicValue(component.label, data, ""));
|
||||
const value =
|
||||
(formData[component.id] as string) ??
|
||||
String(resolveDynamicValue(component.value, data, ""));
|
||||
const isLongText = component.variant === "longText";
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{isLongText ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, e.target.value)}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full min-h-[80px] px-3 py-2 text-sm border rounded-md bg-background resize-y"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={
|
||||
component.variant === "number"
|
||||
? "number"
|
||||
: component.variant === "obscured"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, e.target.value)}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md bg-background"
|
||||
/>
|
||||
)}
|
||||
{component.helperText && (
|
||||
<p className="text-xs text-muted-foreground">{component.helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file 表单组件导出
|
||||
*/
|
||||
|
||||
export { TextFieldRenderer } from "./TextField";
|
||||
export { CheckBoxRenderer } from "./CheckBox";
|
||||
export { ChoicePickerRenderer } from "./ChoicePicker";
|
||||
export { SliderRenderer } from "./Slider";
|
||||
@@ -1,28 +1,14 @@
|
||||
/**
|
||||
* @file A2UI 组件渲染器
|
||||
* @description 渲染 A2UI JSON 为 React 组件
|
||||
* @description 渲染 A2UI JSON 为 React 组件,支持表单数据持久化
|
||||
* @module components/content-creator/a2ui/components
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type {
|
||||
A2UIResponse,
|
||||
A2UIComponent,
|
||||
A2UIEvent,
|
||||
A2UIFormData,
|
||||
ChoicePickerComponent,
|
||||
TextFieldComponent,
|
||||
SliderComponent,
|
||||
CheckBoxComponent,
|
||||
TextComponent,
|
||||
ButtonComponent,
|
||||
RowComponent,
|
||||
ColumnComponent,
|
||||
CardComponent,
|
||||
DividerComponent,
|
||||
} from "../types";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import type { A2UIResponse, A2UIEvent, A2UIFormData } from "../types";
|
||||
import { getComponentById, resolveDynamicValue } from "../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRenderer } from "./ComponentRenderer";
|
||||
|
||||
// ============================================================
|
||||
// 渲染器 Props
|
||||
@@ -33,15 +19,12 @@ interface A2UIRendererProps {
|
||||
onEvent?: (event: A2UIEvent) => void;
|
||||
onSubmit?: (formData: A2UIFormData) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ComponentRendererProps {
|
||||
component: A2UIComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
onAction: (action: A2UIEvent) => void;
|
||||
/** 表单 ID(用于持久化) */
|
||||
formId?: string;
|
||||
/** 初始表单数据(从数据库加载) */
|
||||
initialFormData?: A2UIFormData;
|
||||
/** 表单数据变化回调(用于持久化) */
|
||||
onFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -53,9 +36,19 @@ export function A2UIRenderer({
|
||||
onEvent,
|
||||
onSubmit,
|
||||
className,
|
||||
formId,
|
||||
initialFormData,
|
||||
onFormChange,
|
||||
}: A2UIRendererProps) {
|
||||
// 防抖定时器引用
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<A2UIFormData>(() => {
|
||||
// 初始化表单数据
|
||||
// 优先使用从数据库加载的初始数据
|
||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||
return initialFormData;
|
||||
}
|
||||
// 否则从组件定义中初始化
|
||||
const initial: A2UIFormData = {};
|
||||
for (const comp of response.components) {
|
||||
if ("value" in comp) {
|
||||
@@ -72,14 +65,44 @@ export function A2UIRenderer({
|
||||
return initial;
|
||||
});
|
||||
|
||||
// 当 initialFormData 变化时更新表单数据
|
||||
useEffect(() => {
|
||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
}, [initialFormData]);
|
||||
|
||||
const handleFormChange = useCallback(
|
||||
(id: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [id]: value }));
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [id]: value };
|
||||
|
||||
// 防抖保存到数据库
|
||||
if (formId && onFormChange) {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
onFormChange(formId, newData);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
onEvent?.({ type: "change", componentId: id, value });
|
||||
},
|
||||
[onEvent],
|
||||
[formId, onFormChange, onEvent],
|
||||
);
|
||||
|
||||
// 清理防抖定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(event: A2UIEvent) => {
|
||||
if (event.action?.name === "submit") {
|
||||
@@ -146,514 +169,23 @@ export function A2UIRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件渲染器
|
||||
// ============================================================
|
||||
// 导出组件渲染器供外部使用
|
||||
export { ComponentRenderer } from "./ComponentRenderer";
|
||||
|
||||
function ComponentRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps) {
|
||||
switch (component.component) {
|
||||
case "Row":
|
||||
return (
|
||||
<RowRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Column":
|
||||
return (
|
||||
<ColumnRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Card":
|
||||
return (
|
||||
<CardRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "Divider":
|
||||
return <DividerRenderer component={component} />;
|
||||
case "Text":
|
||||
return <TextRenderer component={component} data={data} />;
|
||||
case "Button":
|
||||
return (
|
||||
<ButtonRenderer
|
||||
component={component}
|
||||
components={components}
|
||||
data={data}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
case "TextField":
|
||||
return (
|
||||
<TextFieldRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "CheckBox":
|
||||
return (
|
||||
<CheckBoxRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "ChoicePicker":
|
||||
return (
|
||||
<ChoicePickerRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
case "Slider":
|
||||
return (
|
||||
<SliderRenderer
|
||||
component={component}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="text-yellow-500">
|
||||
未知组件: {(component as A2UIComponent).component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 导出布局组件
|
||||
export { RowRenderer } from "./layout/Row";
|
||||
export { ColumnRenderer } from "./layout/Column";
|
||||
export { CardRenderer } from "./layout/Card";
|
||||
export { DividerRenderer } from "./layout/Divider";
|
||||
|
||||
// ============================================================
|
||||
// 布局组件
|
||||
// ============================================================
|
||||
// 导出展示组件
|
||||
export { TextRenderer } from "./display/Text";
|
||||
export { ButtonRenderer } from "./display/Button";
|
||||
|
||||
function RowRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps & { component: RowComponent }) {
|
||||
const justifyClass = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
}[component.justify || "start"];
|
||||
|
||||
const alignClass = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
}[component.align || "start"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-row", justifyClass, alignClass)}
|
||||
style={{ gap: component.gap || 8 }}
|
||||
>
|
||||
{component.children.map((childId) => {
|
||||
const child = getComponentById(components, childId);
|
||||
if (!child) return null;
|
||||
return (
|
||||
<ComponentRenderer
|
||||
key={childId}
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps & { component: ColumnComponent }) {
|
||||
const justifyClass = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
}[component.justify || "start"];
|
||||
|
||||
const alignClass = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
}[component.align || "stretch"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col", justifyClass, alignClass)}
|
||||
style={{ gap: component.gap || 12 }}
|
||||
>
|
||||
{component.children.map((childId) => {
|
||||
const child = getComponentById(components, childId);
|
||||
if (!child) return null;
|
||||
return (
|
||||
<ComponentRenderer
|
||||
key={childId}
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps & { component: CardComponent }) {
|
||||
const child = getComponentById(components, component.child);
|
||||
if (!child) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<ComponentRenderer
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DividerRenderer({ component }: { component: DividerComponent }) {
|
||||
const isVertical = component.axis === "vertical";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border",
|
||||
isVertical ? "w-px h-full min-h-[20px]" : "h-px w-full",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 展示组件
|
||||
// ============================================================
|
||||
|
||||
function TextRenderer({
|
||||
component,
|
||||
data,
|
||||
}: {
|
||||
component: TextComponent;
|
||||
data: Record<string, unknown>;
|
||||
}) {
|
||||
const text = resolveDynamicValue(component.text, data, "");
|
||||
|
||||
const variantClass = {
|
||||
h1: "text-2xl font-bold",
|
||||
h2: "text-xl font-semibold",
|
||||
h3: "text-lg font-semibold",
|
||||
h4: "text-base font-medium",
|
||||
body: "text-sm",
|
||||
caption: "text-xs text-muted-foreground",
|
||||
label: "text-sm font-medium",
|
||||
}[component.variant || "body"];
|
||||
|
||||
return <div className={variantClass}>{text}</div>;
|
||||
}
|
||||
|
||||
function ButtonRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
onAction,
|
||||
}: {
|
||||
component: ButtonComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}) {
|
||||
const child = getComponentById(components, component.child);
|
||||
const label =
|
||||
child && child.component === "Text"
|
||||
? resolveDynamicValue((child as TextComponent).text, data, "")
|
||||
: "";
|
||||
|
||||
const handleClick = () => {
|
||||
onAction({
|
||||
type: "action",
|
||||
componentId: component.id,
|
||||
action: component.action,
|
||||
});
|
||||
};
|
||||
|
||||
const variantClass = {
|
||||
filled: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outlined:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
text: "hover:bg-accent hover:text-accent-foreground",
|
||||
}[component.variant || "filled"];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md transition-colors",
|
||||
variantClass,
|
||||
component.primary && "font-medium",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 表单组件
|
||||
// ============================================================
|
||||
|
||||
function TextFieldRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: {
|
||||
component: TextFieldComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}) {
|
||||
const label = resolveDynamicValue(component.label, data, "");
|
||||
const value =
|
||||
(formData[component.id] as string) ??
|
||||
resolveDynamicValue(component.value, data, "");
|
||||
const isLongText = component.variant === "longText";
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{isLongText ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, e.target.value)}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full min-h-[80px] px-3 py-2 text-sm border rounded-md bg-background resize-y"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={
|
||||
component.variant === "number"
|
||||
? "number"
|
||||
: component.variant === "obscured"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, e.target.value)}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md bg-background"
|
||||
/>
|
||||
)}
|
||||
{component.helperText && (
|
||||
<p className="text-xs text-muted-foreground">{component.helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckBoxRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: {
|
||||
component: CheckBoxComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}) {
|
||||
const label = resolveDynamicValue(component.label, data, "");
|
||||
const checked =
|
||||
(formData[component.id] as boolean) ??
|
||||
resolveDynamicValue(component.value, data, false);
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onFormChange(component.id, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoicePickerRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: {
|
||||
component: ChoicePickerComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}) {
|
||||
const label = component.label
|
||||
? resolveDynamicValue(component.label, data, "")
|
||||
: "";
|
||||
const selectedValues =
|
||||
(formData[component.id] as string[]) ??
|
||||
resolveDynamicValue(component.value, data, []);
|
||||
const isMultiple = component.variant === "multipleSelection";
|
||||
const isWrap =
|
||||
component.layout === "wrap" || component.layout === "horizontal";
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (isMultiple) {
|
||||
const newValues = selectedValues.includes(optionValue)
|
||||
? selectedValues.filter((v) => v !== optionValue)
|
||||
: [...selectedValues, optionValue];
|
||||
onFormChange(component.id, newValues);
|
||||
} else {
|
||||
onFormChange(component.id, [optionValue]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <div className="text-sm font-medium">{label}</div>}
|
||||
<div className={cn("flex gap-2", isWrap ? "flex-wrap" : "flex-col")}>
|
||||
{component.options.map((option) => {
|
||||
const optionLabel = resolveDynamicValue(option.label, data, "");
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm rounded-lg border transition-all text-left",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
<span>{optionLabel}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderRenderer({
|
||||
component,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
}: {
|
||||
component: SliderComponent;
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
}) {
|
||||
const label = component.label
|
||||
? resolveDynamicValue(component.label, data, "")
|
||||
: "";
|
||||
const value =
|
||||
(formData[component.id] as number) ??
|
||||
resolveDynamicValue(component.value, data, component.min);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{component.showValue !== false && (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={component.min}
|
||||
max={component.max}
|
||||
step={component.step || 1}
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
{component.marks && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
{component.marks.map((mark) => (
|
||||
<span key={mark.value}>{mark.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 导出表单组件
|
||||
export { TextFieldRenderer } from "./form/TextField";
|
||||
export { CheckBoxRenderer } from "./form/CheckBox";
|
||||
export { ChoicePickerRenderer } from "./form/ChoicePicker";
|
||||
export { SliderRenderer } from "./form/Slider";
|
||||
|
||||
export default A2UIRenderer;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file Card 布局组件
|
||||
* @description 卡片容器
|
||||
*/
|
||||
|
||||
import type {
|
||||
CardComponent,
|
||||
A2UIComponent,
|
||||
A2UIFormData,
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface CardRendererProps {
|
||||
component: CardComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
export function CardRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: CardRendererProps) {
|
||||
const child = getComponentById(components, component.child);
|
||||
if (!child) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<ComponentRenderer
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file Column 布局组件
|
||||
* @description 垂直布局容器
|
||||
*/
|
||||
|
||||
import type {
|
||||
ColumnComponent,
|
||||
A2UIComponent,
|
||||
A2UIFormData,
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface ColumnRendererProps {
|
||||
component: ColumnComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
const justifyClass: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
stretch: "justify-stretch",
|
||||
};
|
||||
|
||||
const alignClass: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
export function ColumnRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ColumnRendererProps) {
|
||||
const childIds = Array.isArray(component.children) ? component.children : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
justifyClass[component.justify || "start"],
|
||||
alignClass[component.align || "stretch"],
|
||||
)}
|
||||
style={{ gap: component.gap || 12 }}
|
||||
>
|
||||
{childIds.map((childId: string) => {
|
||||
const child = getComponentById(components, childId);
|
||||
if (!child) return null;
|
||||
return (
|
||||
<ComponentRenderer
|
||||
key={childId}
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file Divider 布局组件
|
||||
* @description 分隔线
|
||||
*/
|
||||
|
||||
import type { DividerComponent } from "../../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DividerRendererProps {
|
||||
component: DividerComponent;
|
||||
}
|
||||
|
||||
export function DividerRenderer({ component }: DividerRendererProps) {
|
||||
const isVertical = component.axis === "vertical";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border",
|
||||
isVertical ? "w-px h-full min-h-[20px]" : "h-px w-full",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file Row 布局组件
|
||||
* @description 水平布局容器
|
||||
*/
|
||||
|
||||
import type {
|
||||
RowComponent,
|
||||
A2UIComponent,
|
||||
A2UIFormData,
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface RowRendererProps {
|
||||
component: RowComponent;
|
||||
components: A2UIComponent[];
|
||||
data: Record<string, unknown>;
|
||||
formData: A2UIFormData;
|
||||
onFormChange: (id: string, value: unknown) => void;
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
const justifyClass: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
stretch: "justify-stretch",
|
||||
};
|
||||
|
||||
const alignClass: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
export function RowRenderer({
|
||||
component,
|
||||
components,
|
||||
data,
|
||||
formData,
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: RowRendererProps) {
|
||||
const childIds = Array.isArray(component.children) ? component.children : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row",
|
||||
justifyClass[component.justify || "start"],
|
||||
alignClass[component.align || "start"],
|
||||
)}
|
||||
style={{ gap: component.gap || 8 }}
|
||||
>
|
||||
{childIds.map((childId: string) => {
|
||||
const child = getComponentById(components, childId);
|
||||
if (!child) return null;
|
||||
return (
|
||||
<ComponentRenderer
|
||||
key={childId}
|
||||
component={child}
|
||||
components={components}
|
||||
data={data}
|
||||
formData={formData}
|
||||
onFormChange={onFormChange}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file 布局组件导出
|
||||
*/
|
||||
|
||||
export { RowRenderer } from "./Row";
|
||||
export { ColumnRenderer } from "./Column";
|
||||
export { CardRenderer } from "./Card";
|
||||
export { DividerRenderer } from "./Divider";
|
||||
@@ -30,7 +30,7 @@ export type {
|
||||
// 其他类型
|
||||
ChoiceOption,
|
||||
ButtonAction,
|
||||
ValidationRule,
|
||||
CheckRule,
|
||||
DynamicValue,
|
||||
DynamicString,
|
||||
DynamicBoolean,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
/**
|
||||
* @file A2UI 类型定义
|
||||
* @description Agent-to-User Interface 组件类型,参考 Google A2UI 规范
|
||||
* @description Agent-to-User Interface 组件类型,基于 Google A2UI v0.10 规范
|
||||
* @module components/content-creator/a2ui/types
|
||||
* @see https://a2ui.org/specification/v0_10/
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 基础类型
|
||||
// ============================================================
|
||||
|
||||
/** 动态值 - 可以是字面量或数据绑定路径 */
|
||||
export type DynamicValue<T> = T | { path: string };
|
||||
/** 数据绑定 - 引用数据模型中的值 */
|
||||
export interface DataBinding {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** 函数调用 */
|
||||
export interface FunctionCall {
|
||||
call: string;
|
||||
args?: Record<string, unknown>;
|
||||
returnType?: "string" | "number" | "boolean" | "array" | "object" | "void";
|
||||
}
|
||||
|
||||
/** 动态值 - 可以是字面量、数据绑定或函数调用 */
|
||||
export type DynamicValue<T> = T | DataBinding | FunctionCall;
|
||||
|
||||
/** 动态字符串 */
|
||||
export type DynamicString = DynamicValue<string>;
|
||||
@@ -23,36 +36,65 @@ export type DynamicNumber = DynamicValue<number>;
|
||||
/** 动态字符串数组 */
|
||||
export type DynamicStringList = DynamicValue<string[]>;
|
||||
|
||||
/** 检查是否为数据绑定 */
|
||||
export function isDataBinding(value: unknown): value is DataBinding {
|
||||
return typeof value === "object" && value !== null && "path" in value;
|
||||
}
|
||||
|
||||
/** 检查是否为函数调用 */
|
||||
export function isFunctionCall(value: unknown): value is FunctionCall {
|
||||
return typeof value === "object" && value !== null && "call" in value;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件基础
|
||||
// ============================================================
|
||||
|
||||
/** 无障碍属性 */
|
||||
export interface AccessibilityAttributes {
|
||||
/** 无障碍标签 */
|
||||
label?: DynamicString;
|
||||
/** 无障碍描述 */
|
||||
description?: DynamicString;
|
||||
}
|
||||
|
||||
/** 组件通用属性 */
|
||||
export interface ComponentCommon {
|
||||
/** 组件唯一 ID */
|
||||
id: string;
|
||||
/** 是否可见 */
|
||||
/** 无障碍属性 */
|
||||
accessibility?: AccessibilityAttributes;
|
||||
/** 布局权重(仅在 Row/Column 子组件中有效) */
|
||||
weight?: number;
|
||||
/** 是否可见(扩展属性) */
|
||||
visible?: DynamicBoolean;
|
||||
/** 是否禁用 */
|
||||
/** 是否禁用(扩展属性) */
|
||||
disabled?: DynamicBoolean;
|
||||
}
|
||||
|
||||
/** 验证规则 */
|
||||
export interface CheckRule {
|
||||
/** 验证条件(必须返回布尔值) */
|
||||
condition: DynamicBoolean;
|
||||
/** 验证失败时的错误消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 可检查组件(表单组件) */
|
||||
export interface Checkable {
|
||||
/** 验证规则 */
|
||||
validation?: ValidationRule[];
|
||||
/** 验证规则列表 */
|
||||
checks?: CheckRule[];
|
||||
}
|
||||
|
||||
/** 验证规则 */
|
||||
export interface ValidationRule {
|
||||
/** 规则类型 */
|
||||
type: "required" | "regex" | "length" | "numeric" | "email";
|
||||
/** 错误消息 */
|
||||
message?: string;
|
||||
/** 规则参数 */
|
||||
params?: Record<string, unknown>;
|
||||
/** 子组件模板(用于动态列表) */
|
||||
export interface ChildTemplate {
|
||||
componentId: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** 子组件列表 - 静态数组或动态模板 */
|
||||
export type ChildList = string[] | ChildTemplate;
|
||||
|
||||
// ============================================================
|
||||
// 布局组件
|
||||
// ============================================================
|
||||
@@ -60,14 +102,15 @@ export interface ValidationRule {
|
||||
/** 行布局 */
|
||||
export interface RowComponent extends ComponentCommon {
|
||||
component: "Row";
|
||||
children: string[];
|
||||
children: ChildList;
|
||||
justify?:
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "spaceBetween"
|
||||
| "spaceAround"
|
||||
| "spaceEvenly";
|
||||
| "spaceEvenly"
|
||||
| "stretch";
|
||||
align?: "start" | "center" | "end" | "stretch";
|
||||
gap?: number;
|
||||
}
|
||||
@@ -75,18 +118,27 @@ export interface RowComponent extends ComponentCommon {
|
||||
/** 列布局 */
|
||||
export interface ColumnComponent extends ComponentCommon {
|
||||
component: "Column";
|
||||
children: string[];
|
||||
children: ChildList;
|
||||
justify?:
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "spaceBetween"
|
||||
| "spaceAround"
|
||||
| "spaceEvenly";
|
||||
| "spaceEvenly"
|
||||
| "stretch";
|
||||
align?: "start" | "center" | "end" | "stretch";
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/** 列表组件 */
|
||||
export interface ListComponent extends ComponentCommon {
|
||||
component: "List";
|
||||
children: ChildList;
|
||||
direction?: "vertical" | "horizontal";
|
||||
align?: "start" | "center" | "end" | "stretch";
|
||||
}
|
||||
|
||||
/** 卡片 */
|
||||
export interface CardComponent extends ComponentCommon {
|
||||
component: "Card";
|
||||
@@ -94,6 +146,25 @@ export interface CardComponent extends ComponentCommon {
|
||||
variant?: "elevated" | "outlined" | "filled";
|
||||
}
|
||||
|
||||
/** 标签页项 */
|
||||
export interface TabItem {
|
||||
title: DynamicString;
|
||||
child: string;
|
||||
}
|
||||
|
||||
/** 标签页组件 */
|
||||
export interface TabsComponent extends ComponentCommon {
|
||||
component: "Tabs";
|
||||
tabs: TabItem[];
|
||||
}
|
||||
|
||||
/** 模态框组件 */
|
||||
export interface ModalComponent extends ComponentCommon {
|
||||
component: "Modal";
|
||||
trigger: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 分隔线 */
|
||||
export interface DividerComponent extends ComponentCommon {
|
||||
component: "Divider";
|
||||
@@ -108,42 +179,142 @@ export interface DividerComponent extends ComponentCommon {
|
||||
export interface TextComponent extends ComponentCommon {
|
||||
component: "Text";
|
||||
text: DynamicString;
|
||||
variant?: "h1" | "h2" | "h3" | "h4" | "body" | "caption" | "label";
|
||||
variant?: "h1" | "h2" | "h3" | "h4" | "h5" | "body" | "caption";
|
||||
}
|
||||
|
||||
/** 预定义图标名称 */
|
||||
export type PresetIconName =
|
||||
| "accountCircle"
|
||||
| "add"
|
||||
| "arrowBack"
|
||||
| "arrowForward"
|
||||
| "attachFile"
|
||||
| "calendarToday"
|
||||
| "call"
|
||||
| "camera"
|
||||
| "check"
|
||||
| "close"
|
||||
| "delete"
|
||||
| "download"
|
||||
| "edit"
|
||||
| "event"
|
||||
| "error"
|
||||
| "fastForward"
|
||||
| "favorite"
|
||||
| "favoriteOff"
|
||||
| "folder"
|
||||
| "help"
|
||||
| "home"
|
||||
| "info"
|
||||
| "locationOn"
|
||||
| "lock"
|
||||
| "lockOpen"
|
||||
| "mail"
|
||||
| "menu"
|
||||
| "moreVert"
|
||||
| "moreHoriz"
|
||||
| "notificationsOff"
|
||||
| "notifications"
|
||||
| "pause"
|
||||
| "payment"
|
||||
| "person"
|
||||
| "phone"
|
||||
| "photo"
|
||||
| "play"
|
||||
| "print"
|
||||
| "refresh"
|
||||
| "rewind"
|
||||
| "search"
|
||||
| "send"
|
||||
| "settings"
|
||||
| "share"
|
||||
| "shoppingCart"
|
||||
| "skipNext"
|
||||
| "skipPrevious"
|
||||
| "star"
|
||||
| "starHalf"
|
||||
| "starOff"
|
||||
| "stop"
|
||||
| "upload"
|
||||
| "visibility"
|
||||
| "visibilityOff"
|
||||
| "volumeDown"
|
||||
| "volumeMute"
|
||||
| "volumeOff"
|
||||
| "volumeUp"
|
||||
| "warning";
|
||||
|
||||
/** 图标名称 - 预定义或自定义 SVG 路径 */
|
||||
export type IconName = PresetIconName | { path: string };
|
||||
|
||||
/** 图标 */
|
||||
export interface IconComponent extends ComponentCommon {
|
||||
component: "Icon";
|
||||
name: string;
|
||||
size?: "small" | "medium" | "large";
|
||||
name: IconName;
|
||||
}
|
||||
|
||||
/** 图片 */
|
||||
export interface ImageComponent extends ComponentCommon {
|
||||
component: "Image";
|
||||
url: DynamicString;
|
||||
alt?: string;
|
||||
fit?: "contain" | "cover" | "fill" | "none";
|
||||
variant?: "icon" | "avatar" | "thumbnail" | "feature";
|
||||
fit?: "contain" | "cover" | "fill" | "none" | "scale-down";
|
||||
variant?:
|
||||
| "icon"
|
||||
| "avatar"
|
||||
| "smallFeature"
|
||||
| "mediumFeature"
|
||||
| "largeFeature"
|
||||
| "header";
|
||||
}
|
||||
|
||||
/** 视频 */
|
||||
export interface VideoComponent extends ComponentCommon {
|
||||
component: "Video";
|
||||
url: DynamicString;
|
||||
}
|
||||
|
||||
/** 音频播放器 */
|
||||
export interface AudioPlayerComponent extends ComponentCommon {
|
||||
component: "AudioPlayer";
|
||||
url: DynamicString;
|
||||
description?: DynamicString;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 交互组件
|
||||
// ============================================================
|
||||
|
||||
/** 按钮动作 */
|
||||
/** 事件定义 */
|
||||
export interface EventDefinition {
|
||||
name: string;
|
||||
context?: Record<string, DynamicValue<unknown>>;
|
||||
}
|
||||
|
||||
/** 事件动作 */
|
||||
export interface EventAction {
|
||||
event: EventDefinition;
|
||||
}
|
||||
|
||||
/** 函数动作 */
|
||||
export interface FunctionAction {
|
||||
functionCall: FunctionCall;
|
||||
}
|
||||
|
||||
/** 动作 - 服务端事件或客户端函数 */
|
||||
export type Action = EventAction | FunctionAction;
|
||||
|
||||
/** 按钮动作(兼容旧格式) */
|
||||
export interface ButtonAction {
|
||||
name: string;
|
||||
context?: Record<string, DynamicValue<unknown>>;
|
||||
}
|
||||
|
||||
/** 按钮 */
|
||||
export interface ButtonComponent extends ComponentCommon {
|
||||
export interface ButtonComponent extends ComponentCommon, Checkable {
|
||||
component: "Button";
|
||||
child: string;
|
||||
action: ButtonAction;
|
||||
primary?: boolean;
|
||||
variant?: "filled" | "outlined" | "text";
|
||||
action: Action | ButtonAction;
|
||||
variant?: "primary" | "borderless";
|
||||
}
|
||||
|
||||
/** 文本输入框 */
|
||||
@@ -151,8 +322,10 @@ export interface TextFieldComponent extends ComponentCommon, Checkable {
|
||||
component: "TextField";
|
||||
label: DynamicString;
|
||||
value?: DynamicString;
|
||||
placeholder?: string;
|
||||
variant?: "shortText" | "longText" | "number" | "obscured";
|
||||
/** 扩展:占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 扩展:帮助文本 */
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
@@ -167,7 +340,9 @@ export interface CheckBoxComponent extends ComponentCommon, Checkable {
|
||||
export interface ChoiceOption {
|
||||
label: DynamicString;
|
||||
value: string;
|
||||
/** 扩展:选项描述 */
|
||||
description?: string;
|
||||
/** 扩展:选项图标 */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
@@ -178,6 +353,7 @@ export interface ChoicePickerComponent extends ComponentCommon, Checkable {
|
||||
options: ChoiceOption[];
|
||||
value: DynamicStringList;
|
||||
variant?: "mutuallyExclusive" | "multipleSelection";
|
||||
/** 扩展:布局方式 */
|
||||
layout?: "vertical" | "horizontal" | "wrap";
|
||||
}
|
||||
|
||||
@@ -187,9 +363,12 @@ export interface SliderComponent extends ComponentCommon, Checkable {
|
||||
label?: DynamicString;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
value: DynamicNumber;
|
||||
/** 扩展:步长 */
|
||||
step?: number;
|
||||
/** 扩展:是否显示当前值 */
|
||||
showValue?: boolean;
|
||||
/** 扩展:刻度标记 */
|
||||
marks?: { value: number; label: string }[];
|
||||
}
|
||||
|
||||
@@ -200,6 +379,8 @@ export interface DateTimeInputComponent extends ComponentCommon, Checkable {
|
||||
value: DynamicString;
|
||||
enableDate?: boolean;
|
||||
enableTime?: boolean;
|
||||
min?: DynamicString;
|
||||
max?: DynamicString;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -208,13 +389,21 @@ export interface DateTimeInputComponent extends ComponentCommon, Checkable {
|
||||
|
||||
/** 所有组件类型 */
|
||||
export type A2UIComponent =
|
||||
// 布局组件
|
||||
| RowComponent
|
||||
| ColumnComponent
|
||||
| ListComponent
|
||||
| CardComponent
|
||||
| TabsComponent
|
||||
| ModalComponent
|
||||
| DividerComponent
|
||||
// 展示组件
|
||||
| TextComponent
|
||||
| IconComponent
|
||||
| ImageComponent
|
||||
| VideoComponent
|
||||
| AudioPlayerComponent
|
||||
// 交互组件
|
||||
| ButtonComponent
|
||||
| TextFieldComponent
|
||||
| CheckBoxComponent
|
||||
@@ -226,7 +415,61 @@ export type A2UIComponent =
|
||||
export type A2UIComponentType = A2UIComponent["component"];
|
||||
|
||||
// ============================================================
|
||||
// A2UI 响应格式
|
||||
// A2UI 协议消息
|
||||
// ============================================================
|
||||
|
||||
/** 标准组件目录 ID */
|
||||
export const STANDARD_CATALOG_ID =
|
||||
"https://a2ui.org/specification/v0_10/standard_catalog.json";
|
||||
|
||||
/** 主题配置 */
|
||||
export interface Theme {
|
||||
primaryColor?: string;
|
||||
iconUrl?: string;
|
||||
agentDisplayName?: string;
|
||||
}
|
||||
|
||||
/** 创建 Surface 消息 */
|
||||
export interface CreateSurface {
|
||||
surfaceId: string;
|
||||
catalogId: string;
|
||||
theme?: Theme;
|
||||
sendDataModel?: boolean;
|
||||
}
|
||||
|
||||
/** 更新组件消息 */
|
||||
export interface UpdateComponents {
|
||||
surfaceId: string;
|
||||
components: A2UIComponent[];
|
||||
}
|
||||
|
||||
/** 更新数据模型消息 */
|
||||
export interface UpdateDataModel {
|
||||
surfaceId: string;
|
||||
path?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/** 删除 Surface 消息 */
|
||||
export interface DeleteSurface {
|
||||
surfaceId: string;
|
||||
}
|
||||
|
||||
/** 服务端消息类型 */
|
||||
export type ServerMessageContent =
|
||||
| { createSurface: CreateSurface }
|
||||
| { updateComponents: UpdateComponents }
|
||||
| { updateDataModel: UpdateDataModel }
|
||||
| { deleteSurface: DeleteSurface };
|
||||
|
||||
/** 服务端消息 */
|
||||
export interface ServerMessage {
|
||||
version: string;
|
||||
content: ServerMessageContent;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// A2UI 响应格式(简化版,兼容现有实现)
|
||||
// ============================================================
|
||||
|
||||
/** A2UI 响应 */
|
||||
@@ -290,3 +533,191 @@ export interface ParseResult {
|
||||
hasWriteFile?: boolean;
|
||||
hasPending?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 客户端到服务端消息
|
||||
// ============================================================
|
||||
|
||||
/** 动作消息 */
|
||||
export interface ActionMessage {
|
||||
/** 事件名称 */
|
||||
name: string;
|
||||
/** Surface ID */
|
||||
surfaceId: string;
|
||||
/** 触发事件的组件 ID */
|
||||
sourceComponentId: string;
|
||||
/** ISO 8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 事件上下文 */
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 错误代码 */
|
||||
export type ErrorCode = "VALIDATION_FAILED" | string;
|
||||
|
||||
/** 错误消息 */
|
||||
export interface ErrorMessage {
|
||||
/** 错误代码 */
|
||||
code: ErrorCode;
|
||||
/** Surface ID */
|
||||
surfaceId: string;
|
||||
/** 错误消息 */
|
||||
message: string;
|
||||
/** JSON Pointer 路径(仅 VALIDATION_FAILED) */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** 客户端消息类型 */
|
||||
export type ClientMessageContent =
|
||||
| { action: ActionMessage }
|
||||
| { error: ErrorMessage };
|
||||
|
||||
/** 客户端消息 */
|
||||
export interface ClientMessage {
|
||||
version: string;
|
||||
content: ClientMessageContent;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 客户端能力和数据模型(Transport metadata)
|
||||
// ============================================================
|
||||
|
||||
/** 函数定义 */
|
||||
export interface FunctionDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
returnType:
|
||||
| "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "any"
|
||||
| "void";
|
||||
}
|
||||
|
||||
/** 目录定义 */
|
||||
export interface Catalog {
|
||||
catalogId: string;
|
||||
components?: Record<string, unknown>;
|
||||
functions?: FunctionDefinition[];
|
||||
theme?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 客户端能力声明 */
|
||||
export interface ClientCapabilities {
|
||||
"v0.10": {
|
||||
supportedCatalogIds: string[];
|
||||
inlineCatalogs?: Catalog[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 客户端数据模型 */
|
||||
export interface ClientDataModel {
|
||||
version: string;
|
||||
surfaces: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** 创建动作消息 */
|
||||
export function createActionMessage(
|
||||
surfaceId: string,
|
||||
name: string,
|
||||
sourceComponentId: string,
|
||||
context: Record<string, unknown> = {},
|
||||
): ClientMessage {
|
||||
return {
|
||||
version: "v0.10",
|
||||
content: {
|
||||
action: {
|
||||
name,
|
||||
surfaceId,
|
||||
sourceComponentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建验证错误消息 */
|
||||
export function createValidationError(
|
||||
surfaceId: string,
|
||||
path: string,
|
||||
message: string,
|
||||
): ClientMessage {
|
||||
return {
|
||||
version: "v0.10",
|
||||
content: {
|
||||
error: {
|
||||
code: "VALIDATION_FAILED",
|
||||
surfaceId,
|
||||
path,
|
||||
message,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析动态值 */
|
||||
export function resolveDynamicValue<T>(
|
||||
value: DynamicValue<T>,
|
||||
dataModel: Record<string, unknown>,
|
||||
): T | undefined {
|
||||
if (isDataBinding(value)) {
|
||||
return getValueByPath(dataModel, value.path) as T | undefined;
|
||||
}
|
||||
if (isFunctionCall(value)) {
|
||||
// 函数调用需要客户端实现
|
||||
return undefined;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** 根据 JSON Pointer 路径获取值 */
|
||||
export function getValueByPath(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
if (!path || path === "/") return obj;
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
const parts = normalizedPath.split("/");
|
||||
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 根据 JSON Pointer 路径设置值 */
|
||||
export function setValueByPath(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (!path || path === "/") {
|
||||
Object.assign(obj, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
const parts = normalizedPath.split("/");
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* @file AgentChatPanel.tsx
|
||||
* @description AI Agent 对话面板组件
|
||||
* @module components/content-creator/agents/AgentChatPanel
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Send,
|
||||
Loader2,
|
||||
Bot,
|
||||
User,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Wand2,
|
||||
Layout,
|
||||
Palette,
|
||||
Type,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
posterAgentScheduler,
|
||||
type AgentInput,
|
||||
type AgentSuggestion,
|
||||
type PosterAgentId,
|
||||
} from "./index";
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
export type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
/**
|
||||
* 消息
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
/** 消息 ID */
|
||||
id: string;
|
||||
/** 角色 */
|
||||
role: MessageRole;
|
||||
/** 内容 */
|
||||
content: string;
|
||||
/** 时间戳 */
|
||||
timestamp: Date;
|
||||
/** Agent ID */
|
||||
agentId?: PosterAgentId;
|
||||
/** 建议列表 */
|
||||
suggestions?: AgentSuggestion[];
|
||||
/** 是否正在加载 */
|
||||
isLoading?: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷指令
|
||||
*/
|
||||
export interface QuickCommand {
|
||||
/** 指令 ID */
|
||||
id: string;
|
||||
/** 指令名称 */
|
||||
name: string;
|
||||
/** 指令描述 */
|
||||
description: string;
|
||||
/** 图标 */
|
||||
icon: React.ReactNode;
|
||||
/** Agent ID */
|
||||
agentId: PosterAgentId;
|
||||
/** 预设提示词 */
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话面板属性
|
||||
*/
|
||||
export interface AgentChatPanelProps {
|
||||
/** 项目 ID */
|
||||
projectId?: string;
|
||||
/** 品牌人设 ID */
|
||||
brandPersonaId?: string;
|
||||
/** 画布 JSON */
|
||||
canvasJson?: Record<string, unknown>;
|
||||
/** 建议应用回调 */
|
||||
onSuggestionApply?: (suggestion: AgentSuggestion) => void;
|
||||
/** 布局应用回调 */
|
||||
onLayoutApply?: (layoutJson: Record<string, unknown>) => void;
|
||||
/** 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷指令列表
|
||||
*/
|
||||
const quickCommands: QuickCommand[] = [
|
||||
{
|
||||
id: "analyze",
|
||||
name: "分析需求",
|
||||
description: "分析设计需求,提取关键信息",
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
agentId: "requirement",
|
||||
prompt: "请分析我的设计需求",
|
||||
},
|
||||
{
|
||||
id: "style",
|
||||
name: "推荐风格",
|
||||
description: "根据需求推荐合适的视觉风格",
|
||||
icon: <Palette className="h-4 w-4" />,
|
||||
agentId: "style",
|
||||
prompt: "请推荐适合的视觉风格",
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
name: "生成布局",
|
||||
description: "生成海报布局方案",
|
||||
icon: <Layout className="h-4 w-4" />,
|
||||
agentId: "layout",
|
||||
prompt: "请生成一个布局方案",
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
name: "填充内容",
|
||||
description: "为布局填充文案和图片",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
agentId: "content",
|
||||
prompt: "请为当前布局填充内容",
|
||||
},
|
||||
{
|
||||
id: "refine",
|
||||
name: "优化建议",
|
||||
description: "分析并提供优化建议",
|
||||
icon: <Wand2 className="h-4 w-4" />,
|
||||
agentId: "refine",
|
||||
prompt: "请分析当前设计并提供优化建议",
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "导出建议",
|
||||
description: "提供多平台导出建议",
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
agentId: "export",
|
||||
prompt: "请提供导出建议",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息气泡组件
|
||||
*/
|
||||
function MessageBubble({
|
||||
message,
|
||||
onSuggestionApply,
|
||||
onRetry,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
onSuggestionApply?: (suggestion: AgentSuggestion) => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 mb-4",
|
||||
isUser && "flex-row-reverse",
|
||||
isSystem && "justify-center",
|
||||
)}
|
||||
>
|
||||
{/* 头像 */}
|
||||
{!isSystem && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col max-w-[80%]",
|
||||
isUser && "items-end",
|
||||
isSystem && "items-center",
|
||||
)}
|
||||
>
|
||||
{/* Agent 标签 */}
|
||||
{message.agentId && !isUser && (
|
||||
<Badge variant="outline" className="mb-1 text-xs">
|
||||
{getAgentName(message.agentId)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2",
|
||||
isUser && "bg-primary text-primary-foreground",
|
||||
!isUser && !isSystem && "bg-muted",
|
||||
isSystem && "bg-yellow-100 text-yellow-800 text-sm",
|
||||
)}
|
||||
>
|
||||
{message.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>思考中...</span>
|
||||
</div>
|
||||
) : message.error ? (
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span>{message.error}</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 建议列表 */}
|
||||
{message.suggestions && message.suggestions.length > 0 && (
|
||||
<div className="mt-2 space-y-2 w-full">
|
||||
{message.suggestions.map((suggestion, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{suggestion.title}</p>
|
||||
{suggestion.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{suggestion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onSuggestionApply && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSuggestionApply(suggestion)}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
应用
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 名称
|
||||
*/
|
||||
function getAgentName(agentId: PosterAgentId): string {
|
||||
const names: Record<PosterAgentId, string> = {
|
||||
requirement: "需求分析",
|
||||
style: "风格推荐",
|
||||
layout: "布局生成",
|
||||
content: "内容填充",
|
||||
refine: "优化建议",
|
||||
export: "导出处理",
|
||||
};
|
||||
return names[agentId] || agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Agent 对话面板
|
||||
*/
|
||||
export function AgentChatPanel({
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
canvasJson,
|
||||
onSuggestionApply,
|
||||
onLayoutApply,
|
||||
className,
|
||||
}: AgentChatPanelProps) {
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: generateId(),
|
||||
role: "system",
|
||||
content:
|
||||
"你好!我是海报设计助手,可以帮你分析需求、推荐风格、生成布局。试试下面的快捷指令开始吧!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [activeAgent, setActiveAgent] = useState<PosterAgentId | null>(null);
|
||||
|
||||
// Refs
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 消息变化时滚动
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, agentId?: PosterAgentId) => {
|
||||
if (!content.trim() || isProcessing) return;
|
||||
|
||||
const targetAgent = agentId || activeAgent || "requirement";
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue("");
|
||||
setIsProcessing(true);
|
||||
|
||||
// 添加加载消息
|
||||
const loadingMessageId = generateId();
|
||||
const loadingMessage: ChatMessage = {
|
||||
id: loadingMessageId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date(),
|
||||
agentId: targetAgent,
|
||||
isLoading: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, loadingMessage]);
|
||||
|
||||
try {
|
||||
// 构建 Agent 输入
|
||||
const agentInput: AgentInput = {
|
||||
userInput: content,
|
||||
context: {
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
canvasJson,
|
||||
conversationHistory: messages
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
// 执行 Agent
|
||||
const output = await posterAgentScheduler.runAgent(
|
||||
targetAgent,
|
||||
agentInput,
|
||||
);
|
||||
|
||||
// 更新消息
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: output?.summary || "处理完成",
|
||||
suggestions: output?.suggestions,
|
||||
isLoading: false,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
||||
// 如果是布局 Agent,触发布局应用回调
|
||||
if (targetAgent === "layout" && output?.suggestions?.[0]?.content) {
|
||||
const layoutContent = output.suggestions[0].content;
|
||||
if (
|
||||
typeof layoutContent === "object" &&
|
||||
layoutContent !== null &&
|
||||
"fabricJson" in layoutContent
|
||||
) {
|
||||
onLayoutApply?.(
|
||||
(layoutContent as { fabricJson: Record<string, unknown> })
|
||||
.fabricJson,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 更新错误消息
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessageId
|
||||
? {
|
||||
...msg,
|
||||
isLoading: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "处理失败,请重试",
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isProcessing,
|
||||
activeAgent,
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
canvasJson,
|
||||
messages,
|
||||
onLayoutApply,
|
||||
],
|
||||
);
|
||||
|
||||
// 处理快捷指令
|
||||
const handleQuickCommand = useCallback(
|
||||
(command: QuickCommand) => {
|
||||
setActiveAgent(command.agentId);
|
||||
if (command.prompt) {
|
||||
sendMessage(command.prompt, command.agentId);
|
||||
}
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
// 处理建议应用
|
||||
const handleSuggestionApply = useCallback(
|
||||
(suggestion: AgentSuggestion) => {
|
||||
onSuggestionApply?.(suggestion);
|
||||
|
||||
// 添加系统消息
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
role: "system",
|
||||
content: `已应用: ${suggestion.title}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
[onSuggestionApply],
|
||||
);
|
||||
|
||||
// 处理重试
|
||||
const handleRetry = useCallback(
|
||||
(messageId: string) => {
|
||||
const message = messages.find((m) => m.id === messageId);
|
||||
if (message?.agentId) {
|
||||
// 找到对应的用户消息
|
||||
const messageIndex = messages.findIndex((m) => m.id === messageId);
|
||||
const userMessage = messages
|
||||
.slice(0, messageIndex)
|
||||
.reverse()
|
||||
.find((m) => m.role === "user");
|
||||
if (userMessage) {
|
||||
// 移除错误消息
|
||||
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
||||
// 重新发送
|
||||
sendMessage(userMessage.content, message.agentId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[messages, sendMessage],
|
||||
);
|
||||
|
||||
// 处理输入提交
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
sendMessage(inputValue);
|
||||
},
|
||||
[inputValue, sendMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn("flex flex-col h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
AI 设计助手
|
||||
{activeAgent && (
|
||||
<Badge variant="secondary">{getAgentName(activeAgent)}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
|
||||
{/* 快捷指令 */}
|
||||
<div className="px-4 py-2 border-b">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TooltipProvider>
|
||||
{quickCommands.map((command) => (
|
||||
<Tooltip key={command.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={
|
||||
activeAgent === command.agentId ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => handleQuickCommand(command)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{command.icon}
|
||||
<span className="ml-1">{command.name}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{command.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<ScrollArea className="flex-1 px-4 py-2" ref={scrollRef}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
onSuggestionApply={handleSuggestionApply}
|
||||
onRetry={
|
||||
message.error ? () => handleRetry(message.id) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="描述你的设计需求..."
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={isProcessing || !inputValue.trim()}>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentChatPanel;
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file AgentScheduler 测试
|
||||
* @description 测试 Agent 调度器的功能
|
||||
* @module components/content-creator/agents/AgentScheduler.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { PosterAgentScheduler, POSTER_WORKFLOWS } from "./AgentScheduler";
|
||||
import type { AgentInput, AgentOutput, PosterAgentId } from "./base/types";
|
||||
|
||||
// Mock getPosterAgent
|
||||
vi.mock("./poster", () => ({
|
||||
getPosterAgent: vi.fn((id: PosterAgentId) => {
|
||||
if (id === ("invalid" as PosterAgentId)) return undefined;
|
||||
return {
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
summary: `${id} 执行完成`,
|
||||
suggestions: [
|
||||
{
|
||||
id: `${id}-suggestion`,
|
||||
type: "choice",
|
||||
title: `${id} 建议`,
|
||||
content: { result: `${id} result` },
|
||||
reason: "测试原因",
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
} as AgentOutput),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PosterAgentScheduler", () => {
|
||||
let scheduler: PosterAgentScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new PosterAgentScheduler();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runAgent", () => {
|
||||
it("应该成功执行单个 Agent", async () => {
|
||||
const input: AgentInput = {
|
||||
context: { test: true },
|
||||
};
|
||||
|
||||
const result = await scheduler.runAgent("requirement", input);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.suggestions).toHaveLength(1);
|
||||
expect(result?.suggestions[0].id).toBe("requirement-suggestion");
|
||||
});
|
||||
|
||||
it("应该在 Agent 不存在时返回 null", async () => {
|
||||
const input: AgentInput = {
|
||||
context: {},
|
||||
};
|
||||
|
||||
const result = await scheduler.runAgent(
|
||||
"invalid" as PosterAgentId,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWorkflow", () => {
|
||||
it("应该按顺序执行工作流中的所有 Agent", async () => {
|
||||
const input: AgentInput = {
|
||||
context: { initial: true },
|
||||
};
|
||||
|
||||
const stages: PosterAgentId[] = ["requirement", "style"];
|
||||
const results = await scheduler.runWorkflow(stages, input);
|
||||
|
||||
expect(results.size).toBe(2);
|
||||
expect(results.has("requirement")).toBe(true);
|
||||
expect(results.has("style")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该调用进度回调", async () => {
|
||||
const input: AgentInput = {
|
||||
context: {},
|
||||
};
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await scheduler.runWorkflow(["requirement"], input, onProgress);
|
||||
|
||||
// 应该调用两次:开始时 (0) 和完成时 (100)
|
||||
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||
expect(onProgress).toHaveBeenCalledWith("requirement", 0);
|
||||
expect(onProgress).toHaveBeenCalledWith(
|
||||
"requirement",
|
||||
100,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("应该将前一个 Agent 的输出传递给下一个", async () => {
|
||||
const input: AgentInput = {
|
||||
context: { initial: true },
|
||||
};
|
||||
|
||||
const stages: PosterAgentId[] = ["requirement", "style", "layout"];
|
||||
const results = await scheduler.runWorkflow(stages, input);
|
||||
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POSTER_WORKFLOWS", () => {
|
||||
it("full 工作流应该包含所有 6 个 Agent", () => {
|
||||
expect(POSTER_WORKFLOWS.full).toHaveLength(6);
|
||||
expect(POSTER_WORKFLOWS.full).toContain("requirement");
|
||||
expect(POSTER_WORKFLOWS.full).toContain("style");
|
||||
expect(POSTER_WORKFLOWS.full).toContain("layout");
|
||||
expect(POSTER_WORKFLOWS.full).toContain("content");
|
||||
expect(POSTER_WORKFLOWS.full).toContain("refine");
|
||||
expect(POSTER_WORKFLOWS.full).toContain("export");
|
||||
});
|
||||
|
||||
it("quick 工作流应该包含 3 个 Agent", () => {
|
||||
expect(POSTER_WORKFLOWS.quick).toHaveLength(3);
|
||||
expect(POSTER_WORKFLOWS.quick).toContain("style");
|
||||
expect(POSTER_WORKFLOWS.quick).toContain("layout");
|
||||
expect(POSTER_WORKFLOWS.quick).toContain("content");
|
||||
});
|
||||
|
||||
it("optimize 工作流应该包含 2 个 Agent", () => {
|
||||
expect(POSTER_WORKFLOWS.optimize).toHaveLength(2);
|
||||
expect(POSTER_WORKFLOWS.optimize).toContain("refine");
|
||||
expect(POSTER_WORKFLOWS.optimize).toContain("export");
|
||||
});
|
||||
|
||||
it("layout 工作流应该包含 3 个 Agent", () => {
|
||||
expect(POSTER_WORKFLOWS.layout).toHaveLength(3);
|
||||
expect(POSTER_WORKFLOWS.layout).toContain("requirement");
|
||||
expect(POSTER_WORKFLOWS.layout).toContain("style");
|
||||
expect(POSTER_WORKFLOWS.layout).toContain("layout");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file AgentScheduler.ts
|
||||
* @description Agent 调度器,协调多个 Agent 的执行
|
||||
* @module components/content-creator/agents/AgentScheduler
|
||||
*/
|
||||
|
||||
import { getPosterAgent } from "./poster";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
AgentProgressCallback,
|
||||
PosterAgentId,
|
||||
} from "./base/types";
|
||||
|
||||
/**
|
||||
* 海报 Agent 调度器
|
||||
*
|
||||
* 协调多个 Agent 的执行,支持工作流编排。
|
||||
*/
|
||||
export class PosterAgentScheduler {
|
||||
/**
|
||||
* 运行工作流
|
||||
*
|
||||
* @param stages - Agent 执行阶段列表
|
||||
* @param initialInput - 初始输入
|
||||
* @param onProgress - 进度回调
|
||||
* @returns 各阶段的输出结果
|
||||
*/
|
||||
async runWorkflow(
|
||||
stages: PosterAgentId[],
|
||||
initialInput: AgentInput,
|
||||
onProgress?: AgentProgressCallback,
|
||||
): Promise<Map<PosterAgentId, AgentOutput>> {
|
||||
const results = new Map<PosterAgentId, AgentOutput>();
|
||||
let currentInput = initialInput;
|
||||
|
||||
for (const agentId of stages) {
|
||||
const agent = getPosterAgent(agentId);
|
||||
if (!agent) {
|
||||
console.warn(`[AgentScheduler] Agent not found: ${agentId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通知开始
|
||||
onProgress?.(agentId, 0);
|
||||
|
||||
try {
|
||||
// 执行 Agent
|
||||
const output = await agent.execute(currentInput);
|
||||
results.set(agentId, output);
|
||||
|
||||
// 将输出作为下一个 Agent 的输入
|
||||
currentInput = {
|
||||
...currentInput,
|
||||
context: {
|
||||
...currentInput.context,
|
||||
[`${agentId}Result`]: output,
|
||||
// 提取主要建议作为下一阶段的输入
|
||||
...this.extractMainSuggestion(agentId, output),
|
||||
},
|
||||
};
|
||||
|
||||
// 通知完成
|
||||
onProgress?.(agentId, 100, output);
|
||||
} catch (error) {
|
||||
console.error(`[AgentScheduler] Agent ${agentId} failed:`, error);
|
||||
// 通知失败但继续执行
|
||||
onProgress?.(agentId, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行单个 Agent
|
||||
*
|
||||
* @param agentId - Agent ID
|
||||
* @param input - 输入
|
||||
* @returns Agent 输出
|
||||
*/
|
||||
async runAgent(
|
||||
agentId: PosterAgentId,
|
||||
input: AgentInput,
|
||||
): Promise<AgentOutput | null> {
|
||||
const agent = getPosterAgent(agentId);
|
||||
if (!agent) {
|
||||
console.warn(`[AgentScheduler] Agent not found: ${agentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return agent.execute(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取主要建议作为下一阶段的输入
|
||||
*/
|
||||
private extractMainSuggestion(
|
||||
agentId: PosterAgentId,
|
||||
output: AgentOutput,
|
||||
): Record<string, unknown> {
|
||||
const mainSuggestion = output.suggestions[0];
|
||||
if (!mainSuggestion) return {};
|
||||
|
||||
switch (agentId) {
|
||||
case "requirement":
|
||||
return { requirement: mainSuggestion.content };
|
||||
case "style":
|
||||
return { style: mainSuggestion.content };
|
||||
case "layout":
|
||||
return { layout: mainSuggestion.content };
|
||||
case "content":
|
||||
return { filledLayout: mainSuggestion.content };
|
||||
case "refine":
|
||||
return { refinements: output.suggestions.map((s) => s.content) };
|
||||
case "export":
|
||||
return { exportOptions: output.suggestions.map((s) => s.content) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预定义工作流
|
||||
*/
|
||||
export const POSTER_WORKFLOWS = {
|
||||
/**
|
||||
* 完整工作流:从需求到导出
|
||||
*/
|
||||
full: [
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
] as PosterAgentId[],
|
||||
|
||||
/**
|
||||
* 快速工作流:跳过需求分析
|
||||
*/
|
||||
quick: ["style", "layout", "content"] as PosterAgentId[],
|
||||
|
||||
/**
|
||||
* 优化工作流:仅优化和导出
|
||||
*/
|
||||
optimize: ["refine", "export"] as PosterAgentId[],
|
||||
|
||||
/**
|
||||
* 布局工作流:仅生成布局
|
||||
*/
|
||||
layout: ["requirement", "style", "layout"] as PosterAgentId[],
|
||||
};
|
||||
|
||||
// 导出单例
|
||||
export const posterAgentScheduler = new PosterAgentScheduler();
|
||||
|
||||
export default PosterAgentScheduler;
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file BaseAgent.ts
|
||||
* @description Agent 基类,定义 Agent 的基本接口和通用方法
|
||||
* @module components/content-creator/agents/base/BaseAgent
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AgentConfig, AgentInput, AgentOutput } from "./types";
|
||||
|
||||
/**
|
||||
* Agent 基类
|
||||
*
|
||||
* 所有 Agent 都应继承此类并实现 execute 方法。
|
||||
*/
|
||||
export abstract class BaseAgent {
|
||||
protected config: AgentConfig;
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent ID
|
||||
*/
|
||||
get id(): string {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 名称
|
||||
*/
|
||||
get name(): string {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 描述
|
||||
*/
|
||||
get description(): string {
|
||||
return this.config.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Agent 任务
|
||||
*
|
||||
* @param input - Agent 输入
|
||||
* @returns Agent 输出
|
||||
*/
|
||||
abstract execute(input: AgentInput): Promise<AgentOutput>;
|
||||
|
||||
/**
|
||||
* 构建 Prompt
|
||||
*
|
||||
* @param input - Agent 输入
|
||||
* @returns Prompt 字符串
|
||||
*/
|
||||
protected abstract buildPrompt(input: AgentInput): string;
|
||||
|
||||
/**
|
||||
* 调用 LLM
|
||||
*
|
||||
* @param prompt - Prompt 字符串
|
||||
* @returns LLM 响应
|
||||
*/
|
||||
protected async callLLM(prompt: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
// 调用后端 LLM 服务
|
||||
const response = await invoke<string>("agent_chat", {
|
||||
agentId: this.config.id,
|
||||
message: prompt,
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
});
|
||||
|
||||
// 尝试解析 JSON 响应
|
||||
return this.parseResponse(response);
|
||||
} catch (error) {
|
||||
console.error(`[${this.config.id}] LLM 调用失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 响应
|
||||
*
|
||||
* @param response - LLM 响应字符串
|
||||
* @returns 解析后的对象
|
||||
*/
|
||||
protected parseResponse(response: string): Record<string, unknown> {
|
||||
// 尝试提取 JSON 块
|
||||
const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.parse(jsonMatch[1]);
|
||||
} catch {
|
||||
// 继续尝试其他方式
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接解析
|
||||
try {
|
||||
return JSON.parse(response);
|
||||
} catch {
|
||||
// 返回原始响应
|
||||
return { raw: response };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入
|
||||
*
|
||||
* @param input - Agent 输入
|
||||
* @param requiredFields - 必需字段
|
||||
* @throws 如果缺少必需字段
|
||||
*/
|
||||
protected validateInput(input: AgentInput, requiredFields: string[]): void {
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in input.context)) {
|
||||
throw new Error(`缺少必需字段: ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseAgent;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Agent 基础模块导出
|
||||
* @module components/content-creator/agents/base
|
||||
*/
|
||||
|
||||
export { BaseAgent } from "./BaseAgent";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Agent 类型测试
|
||||
* @description 测试 Agent 系统类型定义的正确性
|
||||
* @module components/content-creator/agents/base/types.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentInput,
|
||||
AgentSuggestion,
|
||||
AgentSuggestionType,
|
||||
PosterAgentId,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* AgentSuggestion 生成器
|
||||
*/
|
||||
const agentSuggestionArb = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
type: fc.constantFrom<AgentSuggestionType>(
|
||||
"layout",
|
||||
"element",
|
||||
"style",
|
||||
"text",
|
||||
"choice",
|
||||
),
|
||||
title: fc.string({ minLength: 1, maxLength: 100 }),
|
||||
description: fc.option(fc.string({ maxLength: 500 })),
|
||||
content: fc.anything(),
|
||||
reason: fc.string({ minLength: 1, maxLength: 500 }),
|
||||
confidence: fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
preview: fc.option(fc.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
* AgentOutput 生成器
|
||||
*/
|
||||
const agentOutputArb = fc.record({
|
||||
summary: fc.option(fc.string({ maxLength: 500 })),
|
||||
suggestions: fc.array(agentSuggestionArb, { minLength: 0, maxLength: 10 }),
|
||||
metadata: fc.option(fc.dictionary(fc.string(), fc.anything())),
|
||||
});
|
||||
|
||||
/**
|
||||
* PosterAgentId 生成器
|
||||
*/
|
||||
const posterAgentIdArb = fc.constantFrom<PosterAgentId>(
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
);
|
||||
|
||||
describe("Agent 类型属性测试", () => {
|
||||
/**
|
||||
* Property: AgentSuggestion 的 confidence 应该在 [0, 1] 范围内
|
||||
*/
|
||||
test.prop([agentSuggestionArb])(
|
||||
"AgentSuggestion confidence 应该在 [0, 1] 范围内",
|
||||
(suggestion) => {
|
||||
expect(suggestion.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(suggestion.confidence).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: AgentSuggestion 必须有非空的 id 和 title
|
||||
*/
|
||||
test.prop([agentSuggestionArb])(
|
||||
"AgentSuggestion 必须有非空的 id 和 title",
|
||||
(suggestion) => {
|
||||
expect(suggestion.id.length).toBeGreaterThan(0);
|
||||
expect(suggestion.title.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: AgentOutput 的 suggestions 数组应该是有效的
|
||||
*/
|
||||
test.prop([agentOutputArb])(
|
||||
"AgentOutput suggestions 应该是有效数组",
|
||||
(output) => {
|
||||
expect(Array.isArray(output.suggestions)).toBe(true);
|
||||
output.suggestions.forEach((suggestion) => {
|
||||
expect(suggestion.id).toBeDefined();
|
||||
expect(suggestion.type).toBeDefined();
|
||||
expect(suggestion.title).toBeDefined();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: PosterAgentId 应该是有效的 Agent ID
|
||||
*/
|
||||
test.prop([posterAgentIdArb])(
|
||||
"PosterAgentId 应该是有效的 Agent ID",
|
||||
(agentId) => {
|
||||
const validIds = [
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
];
|
||||
expect(validIds).toContain(agentId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Agent 类型单元测试", () => {
|
||||
it("AgentConfig 应该包含必要字段", () => {
|
||||
const config: AgentConfig = {
|
||||
id: "test-agent",
|
||||
name: "测试 Agent",
|
||||
description: "用于测试的 Agent",
|
||||
model: "gpt-4",
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
expect(config.id).toBe("test-agent");
|
||||
expect(config.name).toBe("测试 Agent");
|
||||
expect(config.description).toBeDefined();
|
||||
});
|
||||
|
||||
it("AgentInput 应该支持可选字段", () => {
|
||||
const minimalInput: AgentInput = {
|
||||
context: {},
|
||||
};
|
||||
|
||||
expect(minimalInput.context).toBeDefined();
|
||||
expect(minimalInput.userInput).toBeUndefined();
|
||||
expect(minimalInput.persona).toBeUndefined();
|
||||
expect(minimalInput.materials).toBeUndefined();
|
||||
});
|
||||
|
||||
it("AgentSuggestionType 应该包含所有有效类型", () => {
|
||||
const validTypes: AgentSuggestionType[] = [
|
||||
"layout",
|
||||
"element",
|
||||
"style",
|
||||
"text",
|
||||
"choice",
|
||||
];
|
||||
|
||||
validTypes.forEach((type) => {
|
||||
const suggestion: AgentSuggestion = {
|
||||
id: "test",
|
||||
type,
|
||||
title: "测试建议",
|
||||
content: {},
|
||||
reason: "测试原因",
|
||||
confidence: 0.8,
|
||||
};
|
||||
expect(suggestion.type).toBe(type);
|
||||
});
|
||||
});
|
||||
|
||||
it("所有 PosterAgentId 应该是有效的", () => {
|
||||
const allAgentIds: PosterAgentId[] = [
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
];
|
||||
|
||||
expect(allAgentIds).toHaveLength(6);
|
||||
allAgentIds.forEach((id) => {
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description Agent 系统类型定义
|
||||
* @module components/content-creator/agents/base/types
|
||||
*/
|
||||
|
||||
import type { BrandPersona } from "@/types/brand-persona";
|
||||
import type { Material } from "@/types/material";
|
||||
|
||||
// ============================================================================
|
||||
// Agent 配置
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agent 配置
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
/** Agent ID */
|
||||
id: string;
|
||||
/** Agent 名称 */
|
||||
name: string;
|
||||
/** Agent 描述 */
|
||||
description: string;
|
||||
/** 使用的模型 */
|
||||
model?: string;
|
||||
/** 温度参数 */
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent 输入输出
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agent 输入
|
||||
*/
|
||||
export interface AgentInput {
|
||||
/** 用户输入 */
|
||||
userInput?: string;
|
||||
/** 上下文数据 */
|
||||
context: Record<string, unknown>;
|
||||
/** 品牌人设 */
|
||||
persona?: BrandPersona;
|
||||
/** 素材列表 */
|
||||
materials?: Material[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 输出
|
||||
*/
|
||||
export interface AgentOutput {
|
||||
/** 摘要 */
|
||||
summary?: string;
|
||||
/** 建议列表 */
|
||||
suggestions: AgentSuggestion[];
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 建议类型
|
||||
*/
|
||||
export type AgentSuggestionType =
|
||||
| "layout"
|
||||
| "element"
|
||||
| "style"
|
||||
| "text"
|
||||
| "choice";
|
||||
|
||||
/**
|
||||
* Agent 建议
|
||||
*/
|
||||
export interface AgentSuggestion {
|
||||
/** 建议 ID */
|
||||
id: string;
|
||||
/** 建议类型 */
|
||||
type: AgentSuggestionType;
|
||||
/** 建议标题 */
|
||||
title: string;
|
||||
/** 建议描述 */
|
||||
description?: string;
|
||||
/** 建议内容 */
|
||||
content: unknown;
|
||||
/** 建议原因 */
|
||||
reason: string;
|
||||
/** 置信度 (0-1) */
|
||||
confidence: number;
|
||||
/** 预览图 URL */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 需求分析
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 需求分析结果
|
||||
*/
|
||||
export interface RequirementAnalysis {
|
||||
/** 设计目的 */
|
||||
purpose: string;
|
||||
/** 目标受众 */
|
||||
audience: {
|
||||
/** 目标人群 */
|
||||
demographic: string;
|
||||
/** 年龄范围 */
|
||||
ageRange: string;
|
||||
/** 兴趣点 */
|
||||
interests: string[];
|
||||
};
|
||||
/** 关键元素 */
|
||||
keyElements: {
|
||||
/** 主要文案 */
|
||||
primaryText: string;
|
||||
/** 次要文案 */
|
||||
secondaryText: string;
|
||||
/** 行动号召 */
|
||||
callToAction: string;
|
||||
};
|
||||
/** 视觉要求 */
|
||||
visualRequirements: {
|
||||
/** 推荐尺寸 */
|
||||
recommendedSize: { width: number; height: number };
|
||||
/** 色彩氛围 */
|
||||
colorMood: string;
|
||||
/** 风格建议 */
|
||||
style: string;
|
||||
};
|
||||
/** 约束条件 */
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 风格推荐
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 风格推荐
|
||||
*/
|
||||
export interface StyleRecommendation {
|
||||
/** 风格 ID */
|
||||
id: string;
|
||||
/** 风格名称 */
|
||||
name: string;
|
||||
/** 风格描述 */
|
||||
description: string;
|
||||
/** 配色方案 */
|
||||
colorPalette: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
/** 字体方案 */
|
||||
typography: {
|
||||
titleFont: string;
|
||||
bodyFont: string;
|
||||
titleSize: number;
|
||||
bodySize: number;
|
||||
};
|
||||
/** 氛围 */
|
||||
mood: string;
|
||||
/** 适用场景 */
|
||||
suitableFor: string[];
|
||||
/** 预览图 */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 布局方案
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fabric.js 对象
|
||||
*/
|
||||
export interface FabricObject {
|
||||
type: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
textAlign?: string;
|
||||
rx?: number;
|
||||
ry?: number;
|
||||
src?: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局方案
|
||||
*/
|
||||
export interface LayoutScheme {
|
||||
/** 布局 ID */
|
||||
id: string;
|
||||
/** 布局名称 */
|
||||
name: string;
|
||||
/** 布局描述 */
|
||||
description: string;
|
||||
/** 缩略图 */
|
||||
thumbnail?: string;
|
||||
/** Fabric.js JSON */
|
||||
fabricJson: {
|
||||
version: string;
|
||||
objects: FabricObject[];
|
||||
};
|
||||
/** 元数据 */
|
||||
metadata: {
|
||||
/** 图片占比 */
|
||||
imageRatio: number;
|
||||
/** 文字占比 */
|
||||
textRatio: number;
|
||||
/** 留白占比 */
|
||||
whiteSpace: number;
|
||||
/** 视觉层次 */
|
||||
hierarchy: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局类型
|
||||
*/
|
||||
export type LayoutType =
|
||||
| "hero-image"
|
||||
| "text-dominant"
|
||||
| "grid"
|
||||
| "split"
|
||||
| "minimal"
|
||||
| "collage";
|
||||
|
||||
// ============================================================================
|
||||
// 内容填充
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 内容填充结果
|
||||
*/
|
||||
export interface ContentFillResult {
|
||||
/** 元素列表 */
|
||||
elements: FabricObject[];
|
||||
/** 文字内容 */
|
||||
textContent: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body?: string;
|
||||
callToAction?: string;
|
||||
};
|
||||
/** 图片推荐 */
|
||||
imageRecommendations: {
|
||||
type: string;
|
||||
description: string;
|
||||
suggestedMaterials: Material[];
|
||||
}[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 优化建议
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 优化建议分类
|
||||
*/
|
||||
export type RefineSuggestionCategory =
|
||||
| "layout"
|
||||
| "typography"
|
||||
| "color"
|
||||
| "alignment"
|
||||
| "hierarchy";
|
||||
|
||||
/**
|
||||
* 优化建议严重程度
|
||||
*/
|
||||
export type RefineSuggestionSeverity = "info" | "warning" | "suggestion";
|
||||
|
||||
/**
|
||||
* 优化建议
|
||||
*/
|
||||
export interface RefineSuggestion {
|
||||
/** 分类 */
|
||||
category: RefineSuggestionCategory;
|
||||
/** 严重程度 */
|
||||
severity: RefineSuggestionSeverity;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 原因 */
|
||||
reason: string;
|
||||
/** 操作 */
|
||||
action: {
|
||||
type: "adjust" | "replace" | "add" | "remove";
|
||||
target: string;
|
||||
property?: string;
|
||||
value?: unknown;
|
||||
};
|
||||
/** 前后对比预览 */
|
||||
beforeAfterPreview?: {
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 导出优化
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 导出优化结果
|
||||
*/
|
||||
export interface ExportOptimization {
|
||||
/** 目标平台 */
|
||||
platform: string;
|
||||
/** 尺寸 */
|
||||
size: { width: number; height: number };
|
||||
/** 格式 */
|
||||
format: "png" | "jpg";
|
||||
/** 质量 */
|
||||
quality: number;
|
||||
/** 调整项 */
|
||||
adjustments: {
|
||||
type: string;
|
||||
description: string;
|
||||
applied: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent 调度
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agent 进度回调
|
||||
*/
|
||||
export type AgentProgressCallback = (
|
||||
agentId: string,
|
||||
progress: number,
|
||||
result?: AgentOutput,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 海报 Agent ID
|
||||
*/
|
||||
export type PosterAgentId =
|
||||
| "requirement"
|
||||
| "style"
|
||||
| "layout"
|
||||
| "content"
|
||||
| "refine"
|
||||
| "export";
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Agent 模块导出
|
||||
* @module components/content-creator/agents
|
||||
*/
|
||||
|
||||
// 基础模块
|
||||
export * from "./base";
|
||||
|
||||
// 海报 Agent
|
||||
export * from "./poster";
|
||||
|
||||
// 调度器
|
||||
export {
|
||||
PosterAgentScheduler,
|
||||
posterAgentScheduler,
|
||||
POSTER_WORKFLOWS,
|
||||
} from "./AgentScheduler";
|
||||
|
||||
// UI 组件
|
||||
export { AgentChatPanel } from "./AgentChatPanel";
|
||||
export type {
|
||||
ChatMessage,
|
||||
MessageRole,
|
||||
QuickCommand,
|
||||
AgentChatPanelProps,
|
||||
} from "./AgentChatPanel";
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file ContentAgent.ts
|
||||
* @description 内容填充 Agent,基于布局和素材生成具体的设计元素
|
||||
* @module components/content-creator/agents/poster/ContentAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
LayoutScheme,
|
||||
StyleRecommendation,
|
||||
FabricObject,
|
||||
} from "../base/types";
|
||||
import type { Material } from "@/types/material";
|
||||
|
||||
/**
|
||||
* 内容填充 Agent
|
||||
*
|
||||
* 基于布局方案和素材库,生成具体的设计元素内容。
|
||||
*/
|
||||
export class ContentAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "content-agent",
|
||||
name: "内容填充 Agent",
|
||||
description: "生成具体的设计元素内容",
|
||||
temperature: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const { layout } = input.context as {
|
||||
layout?: LayoutScheme;
|
||||
};
|
||||
const materials = input.materials || [];
|
||||
|
||||
const prompt = this.buildPrompt(input);
|
||||
const response = await this.callLLM(prompt);
|
||||
|
||||
const content = response.content as {
|
||||
text?: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
callToAction?: string;
|
||||
};
|
||||
images?: Array<{
|
||||
type: string;
|
||||
description: string;
|
||||
position: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// 填充布局中的占位元素
|
||||
const filledLayout = layout
|
||||
? this.fillLayoutContent(layout, content, materials)
|
||||
: null;
|
||||
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
id: "filled-content",
|
||||
type: "element",
|
||||
title: "内容填充完成",
|
||||
description: "基于您的需求和素材生成了设计内容",
|
||||
content: filledLayout,
|
||||
reason: "基于您的需求和素材生成了设计内容",
|
||||
confidence: 0.85,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
textContent: content?.text,
|
||||
imageRecommendations: content?.images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充布局内容
|
||||
*/
|
||||
private fillLayoutContent(
|
||||
layout: LayoutScheme,
|
||||
content: {
|
||||
text?: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
callToAction?: string;
|
||||
};
|
||||
images?: Array<{
|
||||
type: string;
|
||||
description: string;
|
||||
position: string;
|
||||
}>;
|
||||
},
|
||||
materials: Material[],
|
||||
): LayoutScheme {
|
||||
const filledObjects = layout.fabricJson.objects.map((obj) => {
|
||||
// 填充文字内容
|
||||
if (obj.type === "textbox") {
|
||||
if (
|
||||
obj.name === "title" ||
|
||||
obj.text === "主标题文字" ||
|
||||
obj.text === "大标题"
|
||||
) {
|
||||
return { ...obj, text: content?.text?.title || obj.text };
|
||||
}
|
||||
if (
|
||||
obj.name === "subtitle" ||
|
||||
obj.text === "副标题文字" ||
|
||||
obj.text === "副标题描述文字"
|
||||
) {
|
||||
return { ...obj, text: content?.text?.subtitle || obj.text };
|
||||
}
|
||||
if (
|
||||
obj.name === "cta-text" ||
|
||||
obj.text === "立即查看" ||
|
||||
obj.text === "立即购买"
|
||||
) {
|
||||
return { ...obj, text: content?.text?.callToAction || obj.text };
|
||||
}
|
||||
}
|
||||
|
||||
// 标记图片占位区域
|
||||
if (
|
||||
obj.type === "rect" &&
|
||||
obj.fill === "#E0E0E0" &&
|
||||
obj.name?.includes("image")
|
||||
) {
|
||||
// 如果有匹配的素材,添加图片 URL
|
||||
const matchedMaterial = materials.find((m) => m.type === "image");
|
||||
if (matchedMaterial?.content) {
|
||||
return {
|
||||
type: "image",
|
||||
left: obj.left,
|
||||
top: obj.top,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
src: matchedMaterial.content,
|
||||
name: obj.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
});
|
||||
|
||||
return {
|
||||
...layout,
|
||||
fabricJson: {
|
||||
...layout.fabricJson,
|
||||
objects: filledObjects as FabricObject[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected buildPrompt(input: AgentInput): string {
|
||||
const { layout, requirement, style } = input.context as {
|
||||
layout?: LayoutScheme;
|
||||
requirement?: Record<string, unknown>;
|
||||
style?: StyleRecommendation;
|
||||
};
|
||||
|
||||
return `你是一个海报文案专家。请基于以下需求生成海报文案:
|
||||
|
||||
设计需求:
|
||||
${JSON.stringify(requirement, null, 2)}
|
||||
|
||||
布局类型: ${layout?.name || "未指定"}
|
||||
设计风格: ${style?.name || "未指定"}
|
||||
|
||||
请生成以下内容:
|
||||
1. 主标题(简洁有力,8-15 字)
|
||||
2. 副标题(补充说明,15-30 字)
|
||||
3. 行动号召(引导用户,2-6 字)
|
||||
4. 图片建议(需要什么类型的图片)
|
||||
|
||||
输出 JSON 格式:
|
||||
\`\`\`json
|
||||
{
|
||||
"content": {
|
||||
"text": {
|
||||
"title": "主标题内容",
|
||||
"subtitle": "副标题内容",
|
||||
"callToAction": "立即抢购"
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"type": "product",
|
||||
"description": "产品主图,白底高清",
|
||||
"position": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentAgent;
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @file ExportAgent.ts
|
||||
* @description 导出处理 Agent,优化导出设置,适配不同平台
|
||||
* @module components/content-creator/agents/poster/ExportAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
ExportOptimization,
|
||||
} from "../base/types";
|
||||
|
||||
/**
|
||||
* 平台规范
|
||||
*/
|
||||
const PLATFORM_SPECS: Record<
|
||||
string,
|
||||
Record<string, { width: number; height: number; ratio: string }>
|
||||
> = {
|
||||
xiaohongshu: {
|
||||
cover: { width: 1080, height: 1440, ratio: "3:4" },
|
||||
square: { width: 1080, height: 1080, ratio: "1:1" },
|
||||
},
|
||||
wechat: {
|
||||
moment: { width: 1080, height: 1080, ratio: "1:1" },
|
||||
article: { width: 900, height: 383, ratio: "2.35:1" },
|
||||
},
|
||||
taobao: {
|
||||
main: { width: 800, height: 800, ratio: "1:1" },
|
||||
detail: { width: 750, height: 1000, ratio: "3:4" },
|
||||
},
|
||||
douyin: {
|
||||
cover: { width: 1080, height: 1920, ratio: "9:16" },
|
||||
square: { width: 1080, height: 1080, ratio: "1:1" },
|
||||
},
|
||||
weibo: {
|
||||
single: { width: 1080, height: 1080, ratio: "1:1" },
|
||||
long: { width: 1080, height: 1920, ratio: "9:16" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出处理 Agent
|
||||
*
|
||||
* 优化导出设置,适配不同平台的规范要求。
|
||||
*/
|
||||
export class ExportAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "export-agent",
|
||||
name: "导出处理 Agent",
|
||||
description: "优化导出设置,适配不同平台",
|
||||
temperature: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const { currentDesign, targetPlatforms } = input.context as {
|
||||
currentDesign?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
targetPlatforms?: string[];
|
||||
};
|
||||
|
||||
const platforms = targetPlatforms || ["xiaohongshu"];
|
||||
|
||||
const optimizations = platforms.map((platform) =>
|
||||
this.optimizeForPlatform(currentDesign, platform),
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions: optimizations.map((opt) => ({
|
||||
id: `export-${opt.platform}`,
|
||||
type: "choice" as const,
|
||||
title: `${this.getPlatformDisplayName(opt.platform)} 导出`,
|
||||
description: `针对 ${this.getPlatformDisplayName(opt.platform)} 平台优化`,
|
||||
content: opt,
|
||||
reason: `针对 ${this.getPlatformDisplayName(opt.platform)} 平台优化`,
|
||||
confidence: 0.95,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 针对平台优化
|
||||
*/
|
||||
private optimizeForPlatform(
|
||||
design: { width?: number; height?: number } | undefined,
|
||||
platform: string,
|
||||
): ExportOptimization {
|
||||
const spec = PLATFORM_SPECS[platform];
|
||||
const targetSize = spec?.cover ||
|
||||
spec?.main || { width: 1080, height: 1080 };
|
||||
|
||||
const adjustments: ExportOptimization["adjustments"] = [];
|
||||
|
||||
// 检查尺寸是否匹配
|
||||
if (
|
||||
design?.width !== targetSize.width ||
|
||||
design?.height !== targetSize.height
|
||||
) {
|
||||
adjustments.push({
|
||||
type: "resize",
|
||||
description: `调整尺寸为 ${targetSize.width}×${targetSize.height}`,
|
||||
applied: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查安全区域
|
||||
adjustments.push({
|
||||
type: "safe-zone",
|
||||
description: "确保重要内容在安全区域内",
|
||||
applied: true,
|
||||
});
|
||||
|
||||
// 平台特定优化
|
||||
if (platform === "xiaohongshu") {
|
||||
adjustments.push({
|
||||
type: "watermark-space",
|
||||
description: "底部预留水印空间",
|
||||
applied: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === "wechat") {
|
||||
adjustments.push({
|
||||
type: "compression",
|
||||
description: "优化文件大小以适应微信压缩",
|
||||
applied: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 格式建议
|
||||
const format = platform === "wechat" ? "jpg" : "png";
|
||||
const quality = format === "jpg" ? 85 : 100;
|
||||
|
||||
return {
|
||||
platform,
|
||||
size: targetSize,
|
||||
format,
|
||||
quality,
|
||||
adjustments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台显示名称
|
||||
*/
|
||||
private getPlatformDisplayName(platform: string): string {
|
||||
const names: Record<string, string> = {
|
||||
xiaohongshu: "小红书",
|
||||
wechat: "微信",
|
||||
taobao: "淘宝",
|
||||
douyin: "抖音",
|
||||
weibo: "微博",
|
||||
};
|
||||
return names[platform] || platform;
|
||||
}
|
||||
|
||||
protected buildPrompt(_input: AgentInput): string {
|
||||
// ExportAgent 主要基于规则,不需要 LLM
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default ExportAgent;
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* @file LayoutAgent.ts
|
||||
* @description 布局生成 Agent,生成多个布局方案,输出 Fabric.js 兼容格式
|
||||
* @module components/content-creator/agents/poster/LayoutAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
LayoutScheme,
|
||||
StyleRecommendation,
|
||||
FabricObject,
|
||||
} from "../base/types";
|
||||
|
||||
/**
|
||||
* 布局生成 Agent
|
||||
*
|
||||
* 基于需求和风格生成多个布局方案,输出 Fabric.js 兼容的 JSON 格式。
|
||||
*/
|
||||
export class LayoutAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "layout-agent",
|
||||
name: "布局生成 Agent",
|
||||
description: "生成多个布局方案",
|
||||
temperature: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const { style, canvasSize } = input.context as {
|
||||
style?: StyleRecommendation;
|
||||
canvasSize?: { width: number; height: number };
|
||||
};
|
||||
|
||||
const prompt = this.buildPrompt(input);
|
||||
const response = await this.callLLM(prompt);
|
||||
|
||||
const layouts = (response.layouts as Array<Record<string, unknown>>) || [];
|
||||
const size = canvasSize || { width: 1080, height: 1440 };
|
||||
|
||||
// 将 AI 输出转换为 Fabric.js 格式
|
||||
const fabricLayouts = layouts.map((layout, index) =>
|
||||
this.convertToFabricLayout(layout, size, style, index),
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions: fabricLayouts.map((layout, index) => ({
|
||||
id: layout.id,
|
||||
type: "layout" as const,
|
||||
title: layout.name,
|
||||
description: layout.description,
|
||||
content: layout,
|
||||
reason: layout.description,
|
||||
confidence: 1 - index * 0.1,
|
||||
preview: layout.thumbnail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 Fabric.js 布局
|
||||
*/
|
||||
private convertToFabricLayout(
|
||||
layout: Record<string, unknown>,
|
||||
canvasSize: { width: number; height: number },
|
||||
style: StyleRecommendation | undefined,
|
||||
index: number,
|
||||
): LayoutScheme {
|
||||
const { width, height } = canvasSize;
|
||||
const objects: FabricObject[] = [];
|
||||
|
||||
const colorPalette = style?.colorPalette || {
|
||||
primary: "#FF6B9D",
|
||||
secondary: "#FFC0D0",
|
||||
accent: "#FF4081",
|
||||
background: "#FFFFFF",
|
||||
text: "#333333",
|
||||
};
|
||||
|
||||
const typography = style?.typography || {
|
||||
titleFont: "思源黑体",
|
||||
bodyFont: "苹方",
|
||||
titleSize: 72,
|
||||
bodySize: 24,
|
||||
};
|
||||
|
||||
// 背景
|
||||
objects.push({
|
||||
type: "rect",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
fill: colorPalette.background,
|
||||
name: "background",
|
||||
});
|
||||
|
||||
// 根据布局类型生成元素
|
||||
const layoutType = (layout.type as string) || "hero-image";
|
||||
|
||||
switch (layoutType) {
|
||||
case "hero-image":
|
||||
this.generateHeroImageLayout(
|
||||
objects,
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
colorPalette,
|
||||
typography,
|
||||
);
|
||||
break;
|
||||
|
||||
case "text-dominant":
|
||||
this.generateTextDominantLayout(
|
||||
objects,
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
colorPalette,
|
||||
typography,
|
||||
);
|
||||
break;
|
||||
|
||||
case "grid":
|
||||
this.generateGridLayout(
|
||||
objects,
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
colorPalette,
|
||||
typography,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.generateHeroImageLayout(
|
||||
objects,
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
colorPalette,
|
||||
typography,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `layout-${index}`,
|
||||
name: (layout.name as string) || `布局方案 ${index + 1}`,
|
||||
description: (layout.description as string) || "自动生成的布局方案",
|
||||
fabricJson: {
|
||||
version: "5.3.0",
|
||||
objects,
|
||||
},
|
||||
metadata: {
|
||||
imageRatio: (layout.imageRatio as number) || 0.5,
|
||||
textRatio: (layout.textRatio as number) || 0.3,
|
||||
whiteSpace: (layout.whiteSpace as number) || 0.2,
|
||||
hierarchy: (layout.hierarchy as string[]) || ["图片", "标题", "副标题"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成大图型布局
|
||||
*/
|
||||
private generateHeroImageLayout(
|
||||
objects: FabricObject[],
|
||||
width: number,
|
||||
height: number,
|
||||
layout: Record<string, unknown>,
|
||||
colorPalette: StyleRecommendation["colorPalette"],
|
||||
typography: StyleRecommendation["typography"],
|
||||
): void {
|
||||
// 产品图占位
|
||||
objects.push({
|
||||
type: "rect",
|
||||
left: width * 0.1,
|
||||
top: height * 0.1,
|
||||
width: width * 0.8,
|
||||
height: height * 0.5,
|
||||
fill: "#E0E0E0",
|
||||
name: "image-placeholder",
|
||||
});
|
||||
|
||||
// 主标题
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.1,
|
||||
top: height * 0.65,
|
||||
width: width * 0.8,
|
||||
text: (layout.primaryText as string) || "主标题文字",
|
||||
fontSize: typography.titleSize,
|
||||
fontFamily: typography.titleFont,
|
||||
fill: colorPalette.text,
|
||||
name: "title",
|
||||
});
|
||||
|
||||
// 副标题
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.1,
|
||||
top: height * 0.78,
|
||||
width: width * 0.8,
|
||||
text: (layout.secondaryText as string) || "副标题文字",
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.bodyFont,
|
||||
fill: colorPalette.secondary,
|
||||
name: "subtitle",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文字主导型布局
|
||||
*/
|
||||
private generateTextDominantLayout(
|
||||
objects: FabricObject[],
|
||||
width: number,
|
||||
height: number,
|
||||
layout: Record<string, unknown>,
|
||||
colorPalette: StyleRecommendation["colorPalette"],
|
||||
typography: StyleRecommendation["typography"],
|
||||
): void {
|
||||
// 主标题
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.1,
|
||||
top: height * 0.2,
|
||||
width: width * 0.8,
|
||||
text: (layout.primaryText as string) || "大标题",
|
||||
fontSize: typography.titleSize * 1.2,
|
||||
fontFamily: typography.titleFont,
|
||||
fill: colorPalette.primary,
|
||||
name: "title",
|
||||
});
|
||||
|
||||
// 副标题
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.1,
|
||||
top: height * 0.45,
|
||||
width: width * 0.8,
|
||||
text: (layout.secondaryText as string) || "副标题描述文字",
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.bodyFont,
|
||||
fill: colorPalette.text,
|
||||
name: "subtitle",
|
||||
});
|
||||
|
||||
// 行动号召按钮背景
|
||||
objects.push({
|
||||
type: "rect",
|
||||
left: width * 0.25,
|
||||
top: height * 0.7,
|
||||
width: width * 0.5,
|
||||
height: 60,
|
||||
fill: colorPalette.accent,
|
||||
rx: 30,
|
||||
ry: 30,
|
||||
name: "cta-button",
|
||||
});
|
||||
|
||||
// 行动号召文字
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.25,
|
||||
top: height * 0.7 + 15,
|
||||
width: width * 0.5,
|
||||
text: (layout.callToAction as string) || "立即查看",
|
||||
fontSize: 24,
|
||||
fontFamily: typography.bodyFont,
|
||||
fill: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
name: "cta-text",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成九宫格型布局
|
||||
*/
|
||||
private generateGridLayout(
|
||||
objects: FabricObject[],
|
||||
width: number,
|
||||
height: number,
|
||||
layout: Record<string, unknown>,
|
||||
colorPalette: StyleRecommendation["colorPalette"],
|
||||
typography: StyleRecommendation["typography"],
|
||||
): void {
|
||||
const gridSize = 3;
|
||||
const cellWidth = width * 0.25;
|
||||
const cellHeight = height * 0.25;
|
||||
const startX = width * 0.1;
|
||||
const startY = height * 0.15;
|
||||
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
objects.push({
|
||||
type: "rect",
|
||||
left: startX + j * (cellWidth + 10),
|
||||
top: startY + i * (cellHeight + 10),
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
fill: "#E0E0E0",
|
||||
rx: 8,
|
||||
ry: 8,
|
||||
name: `grid-cell-${i}-${j}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 标题
|
||||
objects.push({
|
||||
type: "textbox",
|
||||
left: width * 0.1,
|
||||
top: height * 0.85,
|
||||
width: width * 0.8,
|
||||
text: (layout.primaryText as string) || "标题",
|
||||
fontSize: typography.titleSize * 0.8,
|
||||
fontFamily: typography.titleFont,
|
||||
fill: colorPalette.text,
|
||||
textAlign: "center",
|
||||
name: "title",
|
||||
});
|
||||
}
|
||||
|
||||
protected buildPrompt(input: AgentInput): string {
|
||||
const { requirement, style, canvasSize } = input.context as {
|
||||
requirement?: Record<string, unknown>;
|
||||
style?: StyleRecommendation;
|
||||
canvasSize?: { width: number; height: number };
|
||||
};
|
||||
|
||||
const size = canvasSize || { width: 1080, height: 1440 };
|
||||
|
||||
return `你是一个专业的海报设计师。请基于以下需求生成 3 个不同的布局方案:
|
||||
|
||||
设计需求:
|
||||
${JSON.stringify(requirement, null, 2)}
|
||||
|
||||
设计风格: ${style?.name || "简约现代"}
|
||||
画布尺寸: ${size.width}×${size.height}
|
||||
|
||||
请生成 3 个不同类型的布局:
|
||||
1. hero-image: 大图+标题型(突出产品/主视觉)
|
||||
2. text-dominant: 文字主导型(强调信息传达)
|
||||
3. grid: 九宫格型(展示多个元素)
|
||||
|
||||
每个布局包含:
|
||||
- 布局类型和名称
|
||||
- 元素位置和大小比例
|
||||
- 视觉层次顺序
|
||||
- 图片/文字/留白占比
|
||||
|
||||
输出 JSON 格式:
|
||||
\`\`\`json
|
||||
{
|
||||
"layouts": [
|
||||
{
|
||||
"type": "hero-image",
|
||||
"name": "大图展示",
|
||||
"description": "以产品大图为主,文字为辅,视觉冲击强",
|
||||
"primaryText": "主标题",
|
||||
"secondaryText": "副标题",
|
||||
"callToAction": "立即购买",
|
||||
"imageRatio": 0.5,
|
||||
"textRatio": 0.3,
|
||||
"whiteSpace": 0.2,
|
||||
"hierarchy": ["产品图", "主标题", "副标题", "行动按钮"]
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
export default LayoutAgent;
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file RefineAgent.ts
|
||||
* @description 优化建议 Agent,分析设计稿,提供优化建议
|
||||
* @module components/content-creator/agents/poster/RefineAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
RefineSuggestion,
|
||||
FabricObject,
|
||||
StyleRecommendation,
|
||||
} from "../base/types";
|
||||
|
||||
/**
|
||||
* 优化建议 Agent
|
||||
*
|
||||
* 分析当前设计稿,基于设计规则和 AI 分析提供优化建议。
|
||||
*/
|
||||
export class RefineAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "refine-agent",
|
||||
name: "优化建议 Agent",
|
||||
description: "分析设计稿,提供优化建议",
|
||||
temperature: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const { currentDesign } = input.context as {
|
||||
currentDesign?: {
|
||||
fabricJson?: {
|
||||
objects?: FabricObject[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// 1. 运行规则检查
|
||||
const ruleResults = currentDesign?.fabricJson?.objects
|
||||
? this.runDesignRules(currentDesign.fabricJson.objects)
|
||||
: [];
|
||||
|
||||
// 2. 调用 AI 进行高级分析
|
||||
const prompt = this.buildPrompt(input);
|
||||
const response = await this.callLLM(prompt);
|
||||
|
||||
const aiSuggestions = (response.suggestions as RefineSuggestion[]) || [];
|
||||
|
||||
// 3. 合并建议
|
||||
const allSuggestions = [...ruleResults, ...aiSuggestions];
|
||||
|
||||
return {
|
||||
suggestions: allSuggestions.map((suggestion, index) => ({
|
||||
id: `refine-${index}`,
|
||||
type: "choice" as const,
|
||||
title: suggestion.description,
|
||||
description: suggestion.reason,
|
||||
content: suggestion,
|
||||
reason: suggestion.reason,
|
||||
confidence: suggestion.severity === "warning" ? 0.9 : 0.7,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行设计规则检查
|
||||
*/
|
||||
private runDesignRules(objects: FabricObject[]): RefineSuggestion[] {
|
||||
const suggestions: RefineSuggestion[] = [];
|
||||
|
||||
// 检查对齐
|
||||
const textObjects = objects.filter((o) => o.type === "textbox");
|
||||
if (textObjects.length > 1) {
|
||||
const lefts = textObjects.map((o) => o.left);
|
||||
const uniqueLefts = new Set(lefts);
|
||||
if (uniqueLefts.size > 2) {
|
||||
suggestions.push({
|
||||
category: "alignment",
|
||||
severity: "suggestion",
|
||||
description: "文字元素左对齐不一致",
|
||||
reason: "统一的对齐方式可以提升视觉整洁度",
|
||||
action: {
|
||||
type: "adjust",
|
||||
target: "text-elements",
|
||||
property: "left",
|
||||
value: Math.min(...lefts),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查间距一致性
|
||||
if (textObjects.length > 2) {
|
||||
const tops = textObjects.map((o) => o.top).sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < tops.length; i++) {
|
||||
gaps.push(tops[i] - tops[i - 1]);
|
||||
}
|
||||
const avgGap = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
||||
const hasInconsistentGaps = gaps.some(
|
||||
(gap) => Math.abs(gap - avgGap) > avgGap * 0.3,
|
||||
);
|
||||
if (hasInconsistentGaps) {
|
||||
suggestions.push({
|
||||
category: "layout",
|
||||
severity: "suggestion",
|
||||
description: "文字元素间距不一致",
|
||||
reason: "统一的间距可以提升视觉节奏感",
|
||||
action: {
|
||||
type: "adjust",
|
||||
target: "text-elements",
|
||||
property: "spacing",
|
||||
value: avgGap,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查字号层次
|
||||
const fontSizes = textObjects
|
||||
.map((o) => o.fontSize)
|
||||
.filter((s): s is number => s !== undefined);
|
||||
if (fontSizes.length > 1) {
|
||||
const uniqueSizes = new Set(fontSizes);
|
||||
if (uniqueSizes.size === 1) {
|
||||
suggestions.push({
|
||||
category: "typography",
|
||||
severity: "info",
|
||||
description: "所有文字字号相同",
|
||||
reason: "不同层级的文字应该有明显的字号差异,以建立视觉层次",
|
||||
action: {
|
||||
type: "adjust",
|
||||
target: "title",
|
||||
property: "fontSize",
|
||||
value: Math.max(...fontSizes) * 1.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
protected buildPrompt(input: AgentInput): string {
|
||||
const { currentDesign, style } = input.context as {
|
||||
currentDesign?: Record<string, unknown>;
|
||||
style?: StyleRecommendation;
|
||||
};
|
||||
|
||||
return `你是一个设计审核专家。请分析以下海报设计,提出优化建议:
|
||||
|
||||
设计数据:
|
||||
${JSON.stringify(currentDesign, null, 2)}
|
||||
|
||||
目标风格: ${style?.name || "未指定"}
|
||||
|
||||
请从以下维度分析:
|
||||
1. 布局结构:空间分配是否合理
|
||||
2. 视觉层次:主次关系是否清晰
|
||||
3. 色彩搭配:是否协调统一
|
||||
4. 文字排版:字号、间距是否合适
|
||||
5. 对齐方式:元素是否整齐
|
||||
|
||||
输出 JSON 格式:
|
||||
\`\`\`json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"category": "typography",
|
||||
"severity": "suggestion",
|
||||
"description": "标题与副标题间距建议增加",
|
||||
"reason": "当前间距偏小,影响阅读节奏",
|
||||
"action": {
|
||||
"type": "adjust",
|
||||
"target": "subtitle",
|
||||
"property": "top",
|
||||
"value": "+20"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
export default RefineAgent;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file RequirementAgent.ts
|
||||
* @description 需求分析 Agent,分析用户设计需求,生成结构化需求报告
|
||||
* @module components/content-creator/agents/poster/RequirementAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
RequirementAnalysis,
|
||||
} from "../base/types";
|
||||
|
||||
/**
|
||||
* 需求分析 Agent
|
||||
*
|
||||
* 分析用户的设计需求,提取关键信息,生成结构化的需求报告。
|
||||
*/
|
||||
export class RequirementAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "requirement-agent",
|
||||
name: "需求分析 Agent",
|
||||
description: "分析用户设计需求,生成结构化需求报告",
|
||||
temperature: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const prompt = this.buildPrompt(input);
|
||||
const response = await this.callLLM(prompt);
|
||||
|
||||
const analysis = response.analysis as RequirementAnalysis;
|
||||
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
id: "requirement-analysis",
|
||||
type: "choice",
|
||||
title: "需求分析结果",
|
||||
description: "基于您的输入,我分析了设计需求",
|
||||
content: analysis,
|
||||
reason: "基于您的输入,我分析了设计需求",
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
inputSummary: `${input.context.purpose} - ${input.context.platform}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected buildPrompt(input: AgentInput): string {
|
||||
const { purpose, platform, content, style } = input.context as {
|
||||
purpose?: string;
|
||||
platform?: string;
|
||||
content?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
return `你是一个资深的海报设计师。请分析以下设计需求:
|
||||
|
||||
使用场景: ${purpose || "未指定"}
|
||||
目标平台: ${platform || "未指定"}
|
||||
核心信息: ${content || "未指定"}
|
||||
风格偏好: ${style || "未指定"}
|
||||
|
||||
请输出结构化的需求分析报告,包含:
|
||||
1. 设计目的(吸引点击/传达信息/品牌展示等)
|
||||
2. 目标受众分析(人群特征、年龄、兴趣)
|
||||
3. 关键元素提取(主文案、副文案、行动号召)
|
||||
4. 视觉要求(推荐尺寸、色彩氛围、风格)
|
||||
5. 约束条件(平台规范、品牌要求等)
|
||||
|
||||
输出 JSON 格式:
|
||||
\`\`\`json
|
||||
{
|
||||
"analysis": {
|
||||
"purpose": "设计目的",
|
||||
"audience": {
|
||||
"demographic": "目标人群描述",
|
||||
"ageRange": "18-35岁",
|
||||
"interests": ["兴趣1", "兴趣2"]
|
||||
},
|
||||
"keyElements": {
|
||||
"primaryText": "主要文案",
|
||||
"secondaryText": "次要文案",
|
||||
"callToAction": "立即抢购"
|
||||
},
|
||||
"visualRequirements": {
|
||||
"recommendedSize": { "width": 1080, "height": 1440 },
|
||||
"colorMood": "春季清新、粉色系",
|
||||
"style": "简约现代"
|
||||
},
|
||||
"constraints": ["小红书竖版规范", "需要留出安全区域"]
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
export default RequirementAgent;
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @file StyleAgent.ts
|
||||
* @description 风格推荐 Agent,基于需求推荐设计风格和配色方案
|
||||
* @module components/content-creator/agents/poster/StyleAgent
|
||||
*/
|
||||
|
||||
import { BaseAgent } from "../base/BaseAgent";
|
||||
import type {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
StyleRecommendation,
|
||||
} from "../base/types";
|
||||
import type { BrandPersona } from "@/types/brand-persona";
|
||||
|
||||
/**
|
||||
* 预设风格库
|
||||
*/
|
||||
const PRESET_STYLES: StyleRecommendation[] = [
|
||||
{
|
||||
id: "minimal-modern",
|
||||
name: "简约现代",
|
||||
description: "干净利落的现代设计风格",
|
||||
colorPalette: {
|
||||
primary: "#FF6B9D",
|
||||
secondary: "#FFC0D0",
|
||||
accent: "#FF4081",
|
||||
background: "#FFFFFF",
|
||||
text: "#333333",
|
||||
},
|
||||
typography: {
|
||||
titleFont: "思源黑体",
|
||||
bodyFont: "苹方",
|
||||
titleSize: 72,
|
||||
bodySize: 24,
|
||||
},
|
||||
mood: "时尚、清新",
|
||||
suitableFor: ["时尚品牌", "美妆", "生活方式"],
|
||||
},
|
||||
{
|
||||
id: "vibrant-youth",
|
||||
name: "活力青春",
|
||||
description: "充满活力的年轻化设计",
|
||||
colorPalette: {
|
||||
primary: "#FF9500",
|
||||
secondary: "#FFD166",
|
||||
accent: "#EF476F",
|
||||
background: "#FFFFFF",
|
||||
text: "#2D3436",
|
||||
},
|
||||
typography: {
|
||||
titleFont: "站酷快乐体",
|
||||
bodyFont: "思源黑体",
|
||||
titleSize: 80,
|
||||
bodySize: 22,
|
||||
},
|
||||
mood: "活力、热情",
|
||||
suitableFor: ["运动品牌", "年轻消费品", "娱乐"],
|
||||
},
|
||||
{
|
||||
id: "luxury-elegant",
|
||||
name: "高端奢华",
|
||||
description: "精致优雅的高端设计",
|
||||
colorPalette: {
|
||||
primary: "#D4AF37",
|
||||
secondary: "#1C1C1C",
|
||||
accent: "#C9A86C",
|
||||
background: "#0A0A0A",
|
||||
text: "#FFFFFF",
|
||||
},
|
||||
typography: {
|
||||
titleFont: "方正宋刻本秀楷",
|
||||
bodyFont: "思源宋体",
|
||||
titleSize: 64,
|
||||
bodySize: 20,
|
||||
},
|
||||
mood: "奢华、精致",
|
||||
suitableFor: ["奢侈品", "高端服务", "金融"],
|
||||
},
|
||||
{
|
||||
id: "fresh-natural",
|
||||
name: "清新自然",
|
||||
description: "自然清新的绿色系设计",
|
||||
colorPalette: {
|
||||
primary: "#4CAF50",
|
||||
secondary: "#81C784",
|
||||
accent: "#2E7D32",
|
||||
background: "#F1F8E9",
|
||||
text: "#33691E",
|
||||
},
|
||||
typography: {
|
||||
titleFont: "思源黑体",
|
||||
bodyFont: "苹方",
|
||||
titleSize: 68,
|
||||
bodySize: 22,
|
||||
},
|
||||
mood: "自然、健康",
|
||||
suitableFor: ["健康食品", "环保产品", "户外运动"],
|
||||
},
|
||||
{
|
||||
id: "tech-future",
|
||||
name: "科技未来",
|
||||
description: "科技感十足的未来风格",
|
||||
colorPalette: {
|
||||
primary: "#00BCD4",
|
||||
secondary: "#0097A7",
|
||||
accent: "#00E5FF",
|
||||
background: "#0D1B2A",
|
||||
text: "#E0E0E0",
|
||||
},
|
||||
typography: {
|
||||
titleFont: "思源黑体",
|
||||
bodyFont: "苹方",
|
||||
titleSize: 70,
|
||||
bodySize: 20,
|
||||
},
|
||||
mood: "科技、未来",
|
||||
suitableFor: ["科技产品", "数码设备", "互联网服务"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 风格推荐 Agent
|
||||
*
|
||||
* 基于需求分析结果推荐合适的设计风格和配色方案。
|
||||
*/
|
||||
export class StyleAgent extends BaseAgent {
|
||||
constructor() {
|
||||
super({
|
||||
id: "style-agent",
|
||||
name: "风格推荐 Agent",
|
||||
description: "推荐设计风格和配色方案",
|
||||
temperature: 0.6,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(input: AgentInput): Promise<AgentOutput> {
|
||||
const persona = input.persona;
|
||||
|
||||
// 如果有品牌人设,优先使用品牌风格
|
||||
if (persona) {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
id: "brand-style",
|
||||
type: "style",
|
||||
title: `品牌风格: ${persona.name}`,
|
||||
description: `使用品牌人设「${persona.name}」的设计规范`,
|
||||
content: this.buildStyleFromPersona(persona),
|
||||
reason: `使用品牌人设「${persona.name}」的设计规范`,
|
||||
confidence: 1.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 否则基于需求推荐风格
|
||||
const prompt = this.buildPrompt(input);
|
||||
const response = await this.callLLM(prompt);
|
||||
|
||||
// 匹配最合适的预设风格
|
||||
const keywords = (response.keywords as string[]) || [];
|
||||
const matchedStyles = this.matchStyles(keywords);
|
||||
|
||||
return {
|
||||
suggestions: matchedStyles.map((style, index) => ({
|
||||
id: style.id,
|
||||
type: "style" as const,
|
||||
title: style.name,
|
||||
description: style.description,
|
||||
content: style,
|
||||
reason: `${style.description},适合${style.suitableFor.join("、")}`,
|
||||
confidence: 1 - index * 0.15,
|
||||
preview: style.preview,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从品牌人设构建风格
|
||||
*/
|
||||
private buildStyleFromPersona(persona: BrandPersona): StyleRecommendation {
|
||||
const design = persona.design;
|
||||
const brandTone = persona.brandTone;
|
||||
|
||||
return {
|
||||
id: `brand-${persona.id}`,
|
||||
name: persona.name,
|
||||
description: `${persona.name} 品牌风格`,
|
||||
colorPalette: design?.colorScheme || {
|
||||
primary: "#2196F3",
|
||||
secondary: "#90CAF9",
|
||||
accent: "#1976D2",
|
||||
background: "#FFFFFF",
|
||||
text: "#212121",
|
||||
},
|
||||
typography: design?.typography || {
|
||||
titleFont: "思源黑体",
|
||||
bodyFont: "苹方",
|
||||
titleSize: 72,
|
||||
bodySize: 24,
|
||||
},
|
||||
mood: brandTone?.keywords?.join("、") || "专业",
|
||||
suitableFor: [brandTone?.targetAudience || "通用"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配风格
|
||||
*/
|
||||
private matchStyles(keywords: string[]): StyleRecommendation[] {
|
||||
return PRESET_STYLES.map((style) => ({
|
||||
style,
|
||||
score: this.calculateMatchScore(style, keywords),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map((item) => item.style);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算匹配分数
|
||||
*/
|
||||
private calculateMatchScore(
|
||||
style: StyleRecommendation,
|
||||
keywords: string[],
|
||||
): number {
|
||||
let score = 0;
|
||||
keywords.forEach((keyword) => {
|
||||
if (style.mood.includes(keyword)) score += 2;
|
||||
if (style.suitableFor.some((s) => s.includes(keyword))) score += 1;
|
||||
if (style.name.includes(keyword)) score += 1;
|
||||
});
|
||||
return score;
|
||||
}
|
||||
|
||||
protected buildPrompt(input: AgentInput): string {
|
||||
const { requirement, stylePreference } = input.context as {
|
||||
requirement?: Record<string, unknown>;
|
||||
stylePreference?: string;
|
||||
};
|
||||
|
||||
return `你是一个设计风格专家。请基于以下需求分析,提取关键词用于风格匹配:
|
||||
|
||||
需求分析:
|
||||
${JSON.stringify(requirement, null, 2)}
|
||||
|
||||
用户偏好: ${stylePreference || "未指定"}
|
||||
|
||||
请提取 3-5 个关键词,用于匹配设计风格。关键词应该描述:
|
||||
- 目标受众特征(如:年轻、高端、专业)
|
||||
- 情感氛围(如:活力、温馨、科技)
|
||||
- 行业特征(如:时尚、健康、金融)
|
||||
|
||||
输出 JSON 格式:
|
||||
\`\`\`json
|
||||
{
|
||||
"keywords": ["关键词1", "关键词2", "关键词3"]
|
||||
}
|
||||
\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
export default StyleAgent;
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 海报 Agent 模块导出和注册
|
||||
* @module components/content-creator/agents/poster
|
||||
*/
|
||||
|
||||
import { RequirementAgent } from "./RequirementAgent";
|
||||
import { StyleAgent } from "./StyleAgent";
|
||||
import { LayoutAgent } from "./LayoutAgent";
|
||||
import { ContentAgent } from "./ContentAgent";
|
||||
import { RefineAgent } from "./RefineAgent";
|
||||
import { ExportAgent } from "./ExportAgent";
|
||||
import type { BaseAgent } from "../base/BaseAgent";
|
||||
import type { PosterAgentId } from "../base/types";
|
||||
|
||||
/**
|
||||
* 海报 Agent 注册表
|
||||
*/
|
||||
export const posterAgents: Record<PosterAgentId, BaseAgent> = {
|
||||
requirement: new RequirementAgent(),
|
||||
style: new StyleAgent(),
|
||||
layout: new LayoutAgent(),
|
||||
content: new ContentAgent(),
|
||||
refine: new RefineAgent(),
|
||||
export: new ExportAgent(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取海报 Agent
|
||||
*
|
||||
* @param id - Agent ID
|
||||
* @returns Agent 实例
|
||||
*/
|
||||
export function getPosterAgent(id: PosterAgentId): BaseAgent | undefined {
|
||||
return posterAgents[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有海报 Agent
|
||||
*
|
||||
* @returns Agent 列表
|
||||
*/
|
||||
export function getAllPosterAgents(): BaseAgent[] {
|
||||
return Object.values(posterAgents);
|
||||
}
|
||||
|
||||
// 导出各个 Agent 类
|
||||
export { RequirementAgent } from "./RequirementAgent";
|
||||
export { StyleAgent } from "./StyleAgent";
|
||||
export { LayoutAgent } from "./LayoutAgent";
|
||||
export { ContentAgent } from "./ContentAgent";
|
||||
export { RefineAgent } from "./RefineAgent";
|
||||
export { ExportAgent } from "./ExportAgent";
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* @file PosterDesigner.tsx
|
||||
* @description 海报设计器主组件 - 集成 Agent 对话和画布
|
||||
* @module components/content-creator/canvas/poster/PosterDesigner
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { fabric } from "fabric";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Grid3X3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Download,
|
||||
Save,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentChatPanel } from "../../agents/AgentChatPanel";
|
||||
import { useCanvasAgentBridge } from "./hooks/useCanvasAgentBridge";
|
||||
import { showPlatformSafeZone, hideSafeZone } from "./utils/safeZone";
|
||||
import type { AgentSuggestion, LayoutScheme } from "../../agents/base/types";
|
||||
import type { PlatformId } from "./platforms/types";
|
||||
import { allPlatformSpecs, getRecommendedSize } from "./platforms";
|
||||
|
||||
/**
|
||||
* 海报设计器属性
|
||||
*/
|
||||
export interface PosterDesignerProps {
|
||||
/** 项目 ID */
|
||||
projectId?: string;
|
||||
/** 品牌人设 ID */
|
||||
brandPersonaId?: string;
|
||||
/** 初始画布 JSON */
|
||||
initialCanvasJson?: Record<string, unknown>;
|
||||
/** 保存回调 */
|
||||
onSave?: (canvasJson: Record<string, unknown>) => void;
|
||||
/** 导出回调 */
|
||||
onExport?: (dataUrl: string, format: string) => void;
|
||||
/** 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 海报设计器
|
||||
*/
|
||||
export function PosterDesigner({
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
initialCanvasJson,
|
||||
onSave,
|
||||
onExport,
|
||||
className,
|
||||
}: PosterDesignerProps) {
|
||||
// 画布引用
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [canvas, setCanvas] = useState<fabric.Canvas | null>(null);
|
||||
|
||||
// UI 状态
|
||||
const [showChatPanel, setShowChatPanel] = useState(true);
|
||||
const [showSafeZone, setShowSafeZone] = useState(false);
|
||||
const [currentPlatform, setCurrentPlatform] =
|
||||
useState<PlatformId>("xiaohongshu");
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
// Agent 桥接
|
||||
const {
|
||||
applyLayout,
|
||||
applySuggestion,
|
||||
undoLastOperation,
|
||||
getCanvasSnapshot: _getCanvasSnapshot,
|
||||
operationHistory,
|
||||
isProcessing,
|
||||
lastError,
|
||||
} = useCanvasAgentBridge(canvas);
|
||||
|
||||
// 初始化画布
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const fabricCanvas = new fabric.Canvas(canvasRef.current, {
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
backgroundColor: "#ffffff",
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
|
||||
setCanvas(fabricCanvas);
|
||||
|
||||
// 加载初始内容
|
||||
if (initialCanvasJson) {
|
||||
fabricCanvas.loadFromJSON(initialCanvasJson, () => {
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
fabricCanvas.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 处理布局应用
|
||||
const handleLayoutApply = useCallback(
|
||||
async (layoutJson: Record<string, unknown>) => {
|
||||
if (!canvas) return;
|
||||
|
||||
const layout: LayoutScheme = {
|
||||
id: "applied-layout",
|
||||
name: "应用的布局",
|
||||
description: "",
|
||||
fabricJson: layoutJson as LayoutScheme["fabricJson"],
|
||||
metadata: {
|
||||
imageRatio: 0,
|
||||
textRatio: 0,
|
||||
whiteSpace: 0,
|
||||
hierarchy: [],
|
||||
},
|
||||
};
|
||||
|
||||
await applyLayout(layout);
|
||||
},
|
||||
[canvas, applyLayout],
|
||||
);
|
||||
|
||||
// 处理建议应用
|
||||
const handleSuggestionApply = useCallback(
|
||||
async (suggestion: AgentSuggestion) => {
|
||||
await applySuggestion(suggestion);
|
||||
},
|
||||
[applySuggestion],
|
||||
);
|
||||
|
||||
// 缩放控制
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (!canvas) return;
|
||||
const newZoom = Math.min(zoom * 1.2, 3);
|
||||
canvas.setZoom(newZoom);
|
||||
setZoom(newZoom);
|
||||
}, [canvas, zoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (!canvas) return;
|
||||
const newZoom = Math.max(zoom / 1.2, 0.3);
|
||||
canvas.setZoom(newZoom);
|
||||
setZoom(newZoom);
|
||||
}, [canvas, zoom]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
if (!canvas) return;
|
||||
canvas.setZoom(1);
|
||||
setZoom(1);
|
||||
}, [canvas]);
|
||||
|
||||
// 安全区域切换
|
||||
const handleToggleSafeZone = useCallback(() => {
|
||||
if (!canvas) return;
|
||||
|
||||
if (showSafeZone) {
|
||||
hideSafeZone(canvas);
|
||||
} else {
|
||||
showPlatformSafeZone(canvas, currentPlatform);
|
||||
}
|
||||
setShowSafeZone(!showSafeZone);
|
||||
}, [canvas, showSafeZone, currentPlatform]);
|
||||
|
||||
// 平台切换
|
||||
const handlePlatformChange = useCallback(
|
||||
(platformId: PlatformId) => {
|
||||
setCurrentPlatform(platformId);
|
||||
|
||||
if (!canvas) return;
|
||||
|
||||
// 获取推荐尺寸
|
||||
const recommendedSize = getRecommendedSize(platformId);
|
||||
if (recommendedSize) {
|
||||
canvas.setWidth(recommendedSize.width);
|
||||
canvas.setHeight(recommendedSize.height);
|
||||
canvas.renderAll();
|
||||
}
|
||||
|
||||
// 更新安全区域
|
||||
if (showSafeZone) {
|
||||
hideSafeZone(canvas);
|
||||
showPlatformSafeZone(canvas, platformId);
|
||||
}
|
||||
},
|
||||
[canvas, showSafeZone],
|
||||
);
|
||||
|
||||
// 保存
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canvas || !onSave) return;
|
||||
const json = canvas.toJSON() as Record<string, unknown>;
|
||||
onSave(json);
|
||||
}, [canvas, onSave]);
|
||||
|
||||
// 导出
|
||||
const handleExport = useCallback(
|
||||
(format: "png" | "jpg" | "svg") => {
|
||||
if (!canvas || !onExport) return;
|
||||
|
||||
let dataUrl: string;
|
||||
if (format === "svg") {
|
||||
dataUrl = canvas.toSVG();
|
||||
} else {
|
||||
dataUrl = canvas.toDataURL({
|
||||
format: format,
|
||||
quality: 1,
|
||||
multiplier: 2,
|
||||
});
|
||||
}
|
||||
onExport(dataUrl, format);
|
||||
},
|
||||
[canvas, onExport],
|
||||
);
|
||||
|
||||
// 获取当前画布 JSON
|
||||
const getCanvasJson = useCallback((): Record<string, unknown> | null => {
|
||||
if (!canvas) return null;
|
||||
return canvas.toJSON() as Record<string, unknown>;
|
||||
}, [canvas]);
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 撤销/重做 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={undoLastOperation}
|
||||
disabled={operationHistory.length === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>撤销</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* 缩放控制 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleZoomOut}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>缩小</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleZoomReset}
|
||||
className="min-w-[60px]"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleZoomIn}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>放大</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* 平台选择 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Grid3X3 className="h-4 w-4 mr-2" />
|
||||
{allPlatformSpecs.find((p) => p.id === currentPlatform)?.name ||
|
||||
"选择平台"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{allPlatformSpecs.map((platform) => (
|
||||
<DropdownMenuItem
|
||||
key={platform.id}
|
||||
onClick={() => handlePlatformChange(platform.id)}
|
||||
>
|
||||
{platform.name}
|
||||
{platform.id === currentPlatform && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
当前
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 安全区域 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showSafeZone ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={handleToggleSafeZone}
|
||||
>
|
||||
{showSafeZone ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showSafeZone ? "隐藏安全区域" : "显示安全区域"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 状态指示 */}
|
||||
{isProcessing && <Badge variant="secondary">处理中...</Badge>}
|
||||
{lastError && <Badge variant="destructive">{lastError}</Badge>}
|
||||
|
||||
{/* 对话面板切换 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showChatPanel ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setShowChatPanel(!showChatPanel)}
|
||||
>
|
||||
{showChatPanel ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showChatPanel ? "隐藏 AI 助手" : "显示 AI 助手"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* 保存 */}
|
||||
{onSave && (
|
||||
<Button variant="outline" size="sm" onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 导出 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => handleExport("png")}>
|
||||
导出 PNG
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("jpg")}>
|
||||
导出 JPG
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleExport("svg")}>
|
||||
导出 SVG
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* AI 对话面板 */}
|
||||
{showChatPanel && (
|
||||
<div className="w-[350px] border-r flex-shrink-0">
|
||||
<AgentChatPanel
|
||||
projectId={projectId}
|
||||
brandPersonaId={brandPersonaId}
|
||||
canvasJson={getCanvasJson() || undefined}
|
||||
onSuggestionApply={handleSuggestionApply}
|
||||
onLayoutApply={handleLayoutApply}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/30 overflow-auto p-8">
|
||||
<div
|
||||
className="shadow-lg"
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PosterDesigner;
|
||||
@@ -43,3 +43,9 @@ export type {
|
||||
|
||||
export { useAlignment } from "./useAlignment";
|
||||
export type { UseAlignmentReturn, UseAlignmentOptions } from "./useAlignment";
|
||||
|
||||
export { useAgentIntegration } from "./useAgentIntegration";
|
||||
export type { UseAgentIntegrationReturn } from "./useAgentIntegration";
|
||||
|
||||
export { useCanvasAgentBridge } from "./useCanvasAgentBridge";
|
||||
export type { UseCanvasAgentBridgeReturn } from "./useCanvasAgentBridge";
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @file useAgentIntegration.ts
|
||||
* @description Agent 画布集成 Hook,将 Agent 输出应用到 Fabric.js 画布
|
||||
* @module components/content-creator/canvas/poster/hooks/useAgentIntegration
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { fabric } from "fabric";
|
||||
import type {
|
||||
LayoutScheme,
|
||||
RefineSuggestion,
|
||||
} from "@/components/content-creator/agents/base/types";
|
||||
|
||||
export interface UseAgentIntegrationReturn {
|
||||
/** 应用布局方案到画布 */
|
||||
applyLayout: (layout: LayoutScheme) => Promise<void>;
|
||||
/** 应用单个元素更新 */
|
||||
applyElementUpdate: (
|
||||
targetName: string,
|
||||
property: string,
|
||||
value: unknown,
|
||||
) => void;
|
||||
/** 应用优化建议 */
|
||||
applyRefinement: (suggestion: RefineSuggestion) => void;
|
||||
/** 批量应用优化建议 */
|
||||
applyRefinements: (suggestions: RefineSuggestion[]) => void;
|
||||
/** 获取当前画布 JSON */
|
||||
getCanvasJson: () => Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 画布集成 Hook
|
||||
*
|
||||
* 提供将 Agent 输出应用到 Fabric.js 画布的方法。
|
||||
*
|
||||
* @param canvas - Fabric.js 画布实例
|
||||
*/
|
||||
export function useAgentIntegration(
|
||||
canvas: fabric.Canvas | null,
|
||||
): UseAgentIntegrationReturn {
|
||||
/**
|
||||
* 应用布局方案到画布
|
||||
*/
|
||||
const applyLayout = useCallback(
|
||||
async (layout: LayoutScheme): Promise<void> => {
|
||||
if (!canvas) return;
|
||||
|
||||
// 清空画布
|
||||
canvas.clear();
|
||||
|
||||
// 加载布局
|
||||
return new Promise((resolve) => {
|
||||
canvas.loadFromJSON(layout.fabricJson, () => {
|
||||
canvas.renderAll();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
/**
|
||||
* 应用单个元素更新
|
||||
*/
|
||||
const applyElementUpdate = useCallback(
|
||||
(targetName: string, property: string, value: unknown): void => {
|
||||
if (!canvas) return;
|
||||
|
||||
const objects = canvas.getObjects();
|
||||
const target = objects.find(
|
||||
(obj: fabric.Object) =>
|
||||
(obj as fabric.Object & { name?: string }).name === targetName,
|
||||
);
|
||||
|
||||
if (target) {
|
||||
target.set(property as keyof typeof target, value);
|
||||
canvas.renderAll();
|
||||
}
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
/**
|
||||
* 应用优化建议
|
||||
*/
|
||||
const applyRefinement = useCallback(
|
||||
(suggestion: RefineSuggestion): void => {
|
||||
if (!canvas) return;
|
||||
|
||||
const { action } = suggestion;
|
||||
|
||||
switch (action.type) {
|
||||
case "adjust":
|
||||
if (action.property && action.value !== undefined) {
|
||||
// 处理相对值(如 "+20")
|
||||
const value =
|
||||
typeof action.value === "string" && action.value.startsWith("+")
|
||||
? parseFloat(action.value)
|
||||
: action.value;
|
||||
|
||||
if (action.target === "text-elements") {
|
||||
// 批量调整所有文字元素
|
||||
const textObjects = canvas
|
||||
.getObjects()
|
||||
.filter((obj: fabric.Object) => obj.type === "textbox");
|
||||
textObjects.forEach((obj: fabric.Object) => {
|
||||
if (typeof value === "number") {
|
||||
const currentValue = obj.get(
|
||||
action.property as keyof typeof obj,
|
||||
) as number;
|
||||
obj.set(
|
||||
action.property as keyof typeof obj,
|
||||
currentValue + value,
|
||||
);
|
||||
} else {
|
||||
obj.set(action.property as keyof typeof obj, value);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 调整单个元素
|
||||
applyElementUpdate(action.target, action.property, value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "add":
|
||||
// 添加新元素
|
||||
// TODO: 实现添加元素逻辑
|
||||
break;
|
||||
|
||||
case "remove": {
|
||||
// 移除元素
|
||||
const removeObjects = canvas.getObjects();
|
||||
const targetObj = removeObjects.find(
|
||||
(obj: fabric.Object) =>
|
||||
(obj as fabric.Object & { name?: string }).name === action.target,
|
||||
);
|
||||
if (targetObj) {
|
||||
canvas.remove(targetObj);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "replace":
|
||||
// 替换元素
|
||||
// TODO: 实现替换元素逻辑
|
||||
break;
|
||||
}
|
||||
|
||||
canvas.renderAll();
|
||||
},
|
||||
[canvas, applyElementUpdate],
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量应用优化建议
|
||||
*/
|
||||
const applyRefinements = useCallback(
|
||||
(suggestions: RefineSuggestion[]): void => {
|
||||
suggestions.forEach((suggestion) => {
|
||||
applyRefinement(suggestion);
|
||||
});
|
||||
},
|
||||
[applyRefinement],
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取当前画布 JSON
|
||||
*/
|
||||
const getCanvasJson = useCallback((): Record<string, unknown> | null => {
|
||||
if (!canvas) return null;
|
||||
return canvas.toJSON() as Record<string, unknown>;
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
applyLayout,
|
||||
applyElementUpdate,
|
||||
applyRefinement,
|
||||
applyRefinements,
|
||||
getCanvasJson,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAgentIntegration;
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* @file useCanvasAgentBridge.ts
|
||||
* @description 画布与 Agent 桥接 Hook
|
||||
* @module components/content-creator/canvas/poster/hooks/useCanvasAgentBridge
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { fabric } from "fabric";
|
||||
import type {
|
||||
AgentSuggestion,
|
||||
LayoutScheme,
|
||||
RefineSuggestion,
|
||||
} from "@/components/content-creator/agents/base/types";
|
||||
|
||||
/**
|
||||
* 画布操作历史记录
|
||||
*/
|
||||
interface CanvasOperation {
|
||||
/** 操作 ID */
|
||||
id: string;
|
||||
/** 操作类型 */
|
||||
type: "apply_layout" | "apply_suggestion" | "apply_refinement" | "undo";
|
||||
/** 操作前状态 */
|
||||
beforeState: string;
|
||||
/** 操作后状态 */
|
||||
afterState: string;
|
||||
/** 时间戳 */
|
||||
timestamp: Date;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 返回值
|
||||
*/
|
||||
export interface UseCanvasAgentBridgeReturn {
|
||||
/** 应用布局方案 */
|
||||
applyLayout: (layout: LayoutScheme) => Promise<boolean>;
|
||||
/** 应用 Agent 建议 */
|
||||
applySuggestion: (suggestion: AgentSuggestion) => Promise<boolean>;
|
||||
/** 应用优化建议 */
|
||||
applyRefinement: (refinement: RefineSuggestion) => Promise<boolean>;
|
||||
/** 批量应用优化建议 */
|
||||
applyRefinements: (refinements: RefineSuggestion[]) => Promise<boolean>;
|
||||
/** 撤销上一次操作 */
|
||||
undoLastOperation: () => boolean;
|
||||
/** 获取画布状态快照 */
|
||||
getCanvasSnapshot: () => string | null;
|
||||
/** 从快照恢复 */
|
||||
restoreFromSnapshot: (snapshot: string) => Promise<boolean>;
|
||||
/** 操作历史 */
|
||||
operationHistory: CanvasOperation[];
|
||||
/** 是否正在处理 */
|
||||
isProcessing: boolean;
|
||||
/** 最后一次错误 */
|
||||
lastError: string | null;
|
||||
/** 清除错误 */
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return `op_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布与 Agent 桥接 Hook
|
||||
*
|
||||
* 提供画布与 Agent 系统之间的交互桥接。
|
||||
*
|
||||
* @param canvas - Fabric.js 画布实例
|
||||
*/
|
||||
export function useCanvasAgentBridge(
|
||||
canvas: fabric.Canvas | null,
|
||||
): UseCanvasAgentBridgeReturn {
|
||||
const [operationHistory, setOperationHistory] = useState<CanvasOperation[]>(
|
||||
[],
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
// 保存操作前状态
|
||||
const beforeStateRef = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* 获取画布状态快照
|
||||
*/
|
||||
const getCanvasSnapshot = useCallback((): string | null => {
|
||||
if (!canvas) return null;
|
||||
return JSON.stringify(canvas.toJSON());
|
||||
}, [canvas]);
|
||||
|
||||
/**
|
||||
* 从快照恢复
|
||||
*/
|
||||
const restoreFromSnapshot = useCallback(
|
||||
async (snapshot: string): Promise<boolean> => {
|
||||
if (!canvas) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const json = JSON.parse(snapshot);
|
||||
canvas.loadFromJSON(json, () => {
|
||||
canvas.renderAll();
|
||||
resolve(true);
|
||||
});
|
||||
} catch (_error) {
|
||||
setLastError("恢复快照失败");
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
/**
|
||||
* 记录操作
|
||||
*/
|
||||
const recordOperation = useCallback(
|
||||
(
|
||||
type: CanvasOperation["type"],
|
||||
description: string,
|
||||
beforeState: string,
|
||||
afterState: string,
|
||||
) => {
|
||||
const operation: CanvasOperation = {
|
||||
id: generateId(),
|
||||
type,
|
||||
beforeState,
|
||||
afterState,
|
||||
timestamp: new Date(),
|
||||
description,
|
||||
};
|
||||
setOperationHistory((prev) => [...prev, operation]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 应用布局方案
|
||||
*/
|
||||
const applyLayout = useCallback(
|
||||
async (layout: LayoutScheme): Promise<boolean> => {
|
||||
if (!canvas) {
|
||||
setLastError("画布未初始化");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setLastError(null);
|
||||
beforeStateRef.current = getCanvasSnapshot();
|
||||
|
||||
try {
|
||||
// 清空画布
|
||||
canvas.clear();
|
||||
|
||||
// 加载布局 JSON
|
||||
return new Promise((resolve) => {
|
||||
canvas.loadFromJSON(layout.fabricJson, () => {
|
||||
canvas.renderAll();
|
||||
|
||||
// 记录操作
|
||||
const afterState = getCanvasSnapshot();
|
||||
if (beforeStateRef.current && afterState) {
|
||||
recordOperation(
|
||||
"apply_layout",
|
||||
`应用布局: ${layout.name}`,
|
||||
beforeStateRef.current,
|
||||
afterState,
|
||||
);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setLastError(error instanceof Error ? error.message : "应用布局失败");
|
||||
setIsProcessing(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[canvas, getCanvasSnapshot, recordOperation],
|
||||
);
|
||||
|
||||
/**
|
||||
* 应用 Agent 建议
|
||||
*/
|
||||
const applySuggestion = useCallback(
|
||||
async (suggestion: AgentSuggestion): Promise<boolean> => {
|
||||
if (!canvas) {
|
||||
setLastError("画布未初始化");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setLastError(null);
|
||||
beforeStateRef.current = getCanvasSnapshot();
|
||||
|
||||
try {
|
||||
const content = suggestion.content;
|
||||
|
||||
// 根据建议类型处理
|
||||
if (typeof content === "object" && content !== null) {
|
||||
// 如果是布局方案
|
||||
if ("fabricJson" in content) {
|
||||
return applyLayout(content as LayoutScheme);
|
||||
}
|
||||
|
||||
// 如果是优化建议
|
||||
if ("action" in content) {
|
||||
return applyRefinement(content as RefineSuggestion);
|
||||
}
|
||||
|
||||
// 如果是样式更新
|
||||
if ("property" in content && "value" in content) {
|
||||
const { target, property, value } = content as {
|
||||
target?: string;
|
||||
property: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
if (target) {
|
||||
// 更新指定元素
|
||||
const objects = canvas.getObjects();
|
||||
const targetObj = objects.find(
|
||||
(obj) =>
|
||||
(obj as fabric.Object & { name?: string }).name === target,
|
||||
);
|
||||
if (targetObj) {
|
||||
targetObj.set(property as keyof typeof targetObj, value);
|
||||
canvas.renderAll();
|
||||
}
|
||||
} else {
|
||||
// 更新选中元素
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (activeObject) {
|
||||
activeObject.set(property as keyof typeof activeObject, value);
|
||||
canvas.renderAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录操作
|
||||
const afterState = getCanvasSnapshot();
|
||||
if (beforeStateRef.current && afterState) {
|
||||
recordOperation(
|
||||
"apply_suggestion",
|
||||
`应用建议: ${suggestion.title}`,
|
||||
beforeStateRef.current,
|
||||
afterState,
|
||||
);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLastError(error instanceof Error ? error.message : "应用建议失败");
|
||||
setIsProcessing(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[canvas, getCanvasSnapshot, recordOperation, applyLayout],
|
||||
);
|
||||
|
||||
/**
|
||||
* 应用优化建议
|
||||
*/
|
||||
const applyRefinement = useCallback(
|
||||
async (refinement: RefineSuggestion): Promise<boolean> => {
|
||||
if (!canvas) {
|
||||
setLastError("画布未初始化");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setLastError(null);
|
||||
beforeStateRef.current = getCanvasSnapshot();
|
||||
|
||||
try {
|
||||
const { action } = refinement;
|
||||
|
||||
switch (action.type) {
|
||||
case "adjust": {
|
||||
if (!action.property || action.value === undefined) break;
|
||||
|
||||
const objects = canvas.getObjects();
|
||||
let targets: fabric.Object[] = [];
|
||||
|
||||
// 确定目标元素
|
||||
if (action.target === "text-elements") {
|
||||
targets = objects.filter((obj) => obj.type === "textbox");
|
||||
} else if (action.target === "all") {
|
||||
targets = objects;
|
||||
} else {
|
||||
const target = objects.find(
|
||||
(obj) =>
|
||||
(obj as fabric.Object & { name?: string }).name ===
|
||||
action.target,
|
||||
);
|
||||
if (target) targets = [target];
|
||||
}
|
||||
|
||||
// 应用调整
|
||||
targets.forEach((obj) => {
|
||||
let value = action.value;
|
||||
|
||||
// 处理相对值
|
||||
if (typeof value === "string" && value.startsWith("+")) {
|
||||
const delta = parseFloat(value);
|
||||
const current = obj.get(
|
||||
action.property as keyof typeof obj,
|
||||
) as number;
|
||||
value = current + delta;
|
||||
} else if (typeof value === "string" && value.startsWith("-")) {
|
||||
const delta = parseFloat(value);
|
||||
const current = obj.get(
|
||||
action.property as keyof typeof obj,
|
||||
) as number;
|
||||
value = current + delta;
|
||||
}
|
||||
|
||||
obj.set(action.property as keyof typeof obj, value);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const objects = canvas.getObjects();
|
||||
const target = objects.find(
|
||||
(obj) =>
|
||||
(obj as fabric.Object & { name?: string }).name ===
|
||||
action.target,
|
||||
);
|
||||
if (target) {
|
||||
canvas.remove(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "add": {
|
||||
// TODO: 实现添加元素逻辑
|
||||
break;
|
||||
}
|
||||
|
||||
case "replace": {
|
||||
// TODO: 实现替换元素逻辑
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.renderAll();
|
||||
|
||||
// 记录操作
|
||||
const afterState = getCanvasSnapshot();
|
||||
if (beforeStateRef.current && afterState) {
|
||||
recordOperation(
|
||||
"apply_refinement",
|
||||
`应用优化: ${refinement.description}`,
|
||||
beforeStateRef.current,
|
||||
afterState,
|
||||
);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLastError(
|
||||
error instanceof Error ? error.message : "应用优化建议失败",
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[canvas, getCanvasSnapshot, recordOperation],
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量应用优化建议
|
||||
*/
|
||||
const applyRefinements = useCallback(
|
||||
async (refinements: RefineSuggestion[]): Promise<boolean> => {
|
||||
if (!canvas || refinements.length === 0) return false;
|
||||
|
||||
setIsProcessing(true);
|
||||
beforeStateRef.current = getCanvasSnapshot();
|
||||
|
||||
let success = true;
|
||||
for (const refinement of refinements) {
|
||||
const result = await applyRefinement(refinement);
|
||||
if (!result) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
return success;
|
||||
},
|
||||
[canvas, getCanvasSnapshot, applyRefinement],
|
||||
);
|
||||
|
||||
/**
|
||||
* 撤销上一次操作
|
||||
*/
|
||||
const undoLastOperation = useCallback((): boolean => {
|
||||
if (!canvas || operationHistory.length === 0) return false;
|
||||
|
||||
const lastOperation = operationHistory[operationHistory.length - 1];
|
||||
|
||||
try {
|
||||
const json = JSON.parse(lastOperation.beforeState);
|
||||
canvas.loadFromJSON(json, () => {
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
// 移除最后一条记录
|
||||
setOperationHistory((prev) => prev.slice(0, -1));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
setLastError("撤销操作失败");
|
||||
return false;
|
||||
}
|
||||
}, [canvas, operationHistory]);
|
||||
|
||||
/**
|
||||
* 清除错误
|
||||
*/
|
||||
const clearError = useCallback(() => {
|
||||
setLastError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
applyLayout,
|
||||
applySuggestion,
|
||||
applyRefinement,
|
||||
applyRefinements,
|
||||
undoLastOperation,
|
||||
getCanvasSnapshot,
|
||||
restoreFromSnapshot,
|
||||
operationHistory,
|
||||
isProcessing,
|
||||
lastError,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCanvasAgentBridge;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @file douyin.ts
|
||||
* @description 抖音平台规范
|
||||
* @module components/content-creator/canvas/poster/platforms/douyin
|
||||
*/
|
||||
|
||||
import type { PlatformSpec } from "./types";
|
||||
|
||||
/**
|
||||
* 抖音平台规范
|
||||
*/
|
||||
export const douyinSpec: PlatformSpec = {
|
||||
id: "douyin",
|
||||
name: "抖音",
|
||||
icon: "douyin",
|
||||
description: "抖音视频封面、图文、直播规范",
|
||||
sizes: [
|
||||
{
|
||||
name: "竖版视频封面 9:16",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
aspectRatio: "9:16",
|
||||
usage: "竖版视频封面,最常用格式",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: "横版视频封面 16:9",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
aspectRatio: "16:9",
|
||||
usage: "横版视频封面",
|
||||
},
|
||||
{
|
||||
name: "方形视频封面 1:1",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "方形视频封面",
|
||||
},
|
||||
{
|
||||
name: "图文笔记 3:4",
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
aspectRatio: "3:4",
|
||||
usage: "抖音图文笔记",
|
||||
},
|
||||
{
|
||||
name: "直播封面",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
aspectRatio: "9:16",
|
||||
usage: "直播间封面图",
|
||||
},
|
||||
{
|
||||
name: "商品橱窗图",
|
||||
width: 800,
|
||||
height: 800,
|
||||
aspectRatio: "1:1",
|
||||
usage: "抖音小店商品图",
|
||||
},
|
||||
],
|
||||
safeZone: {
|
||||
top: 150,
|
||||
bottom: 300,
|
||||
left: 40,
|
||||
right: 40,
|
||||
description: "顶部有状态栏,底部有互动按钮和文字区域",
|
||||
},
|
||||
fileSpec: {
|
||||
formats: ["jpg", "png", "webp"],
|
||||
maxSizeKB: 15360, // 15MB
|
||||
recommendedDPI: 72,
|
||||
colorMode: "RGB",
|
||||
},
|
||||
textSpec: {
|
||||
minFontSize: 28,
|
||||
recommendedTitleSize: 56,
|
||||
recommendedBodySize: 36,
|
||||
lineHeightRatio: 1.4,
|
||||
},
|
||||
notes: [
|
||||
"封面是吸引点击的关键,建议使用高对比度",
|
||||
"底部1/4区域会被文字和按钮遮挡",
|
||||
"顶部有状态栏,避免放置重要信息",
|
||||
"建议使用大字体,确保在信息流中清晰",
|
||||
"封面人物建议看向镜头或画面中心",
|
||||
"避免使用过于复杂的背景",
|
||||
],
|
||||
guideUrl: "https://creator.douyin.com",
|
||||
};
|
||||
|
||||
export default douyinSpec;
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file 平台规范测试
|
||||
* @description 测试多平台导出规范的正确性
|
||||
* @module components/content-creator/canvas/poster/platforms/index.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
allPlatformSpecs,
|
||||
getPlatformSpec,
|
||||
getRecommendedSize,
|
||||
checkFileCompliance,
|
||||
xiaohongshuSpec,
|
||||
wechatSpec,
|
||||
taobaoSpec,
|
||||
douyinSpec,
|
||||
} from "./index";
|
||||
import type { PlatformId } from "./types";
|
||||
|
||||
/**
|
||||
* PlatformId 生成器
|
||||
*/
|
||||
const platformIdArb = fc.constantFrom<PlatformId>(
|
||||
"xiaohongshu",
|
||||
"wechat",
|
||||
"taobao",
|
||||
"douyin",
|
||||
);
|
||||
|
||||
/**
|
||||
* 文件大小生成器 (KB)
|
||||
*/
|
||||
const fileSizeArb = fc.integer({ min: 1, max: 50000 });
|
||||
|
||||
/**
|
||||
* 文件格式生成器
|
||||
*/
|
||||
const fileFormatArb = fc.constantFrom("jpg", "png", "gif", "webp", "svg");
|
||||
|
||||
describe("平台规范属性测试", () => {
|
||||
/**
|
||||
* Property: 所有平台规范应该有有效的尺寸列表
|
||||
*/
|
||||
test.prop([platformIdArb])("每个平台应该至少有一个尺寸规格", (platformId) => {
|
||||
const spec = getPlatformSpec(platformId);
|
||||
expect(spec).toBeDefined();
|
||||
expect(spec!.sizes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property: 所有尺寸规格应该有正数的宽高
|
||||
*/
|
||||
test.prop([platformIdArb])("所有尺寸规格的宽高应该是正数", (platformId) => {
|
||||
const spec = getPlatformSpec(platformId);
|
||||
spec!.sizes.forEach((size) => {
|
||||
expect(size.width).toBeGreaterThan(0);
|
||||
// 高度可以是 0(表示不限制,如淘宝详情页长图)
|
||||
expect(size.height).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property: 文件合规性检查应该正确处理边界情况
|
||||
*/
|
||||
test.prop([platformIdArb, fileSizeArb, fileFormatArb])(
|
||||
"文件合规性检查应该返回有效结果",
|
||||
(platformId, fileSize, format) => {
|
||||
const result = checkFileCompliance(platformId, fileSize, format);
|
||||
|
||||
expect(typeof result.valid).toBe("boolean");
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
|
||||
// 如果有效,错误列表应该为空
|
||||
if (result.valid) {
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: 推荐尺寸应该存在于平台的尺寸列表中
|
||||
*/
|
||||
test.prop([platformIdArb])(
|
||||
"推荐尺寸应该是平台尺寸列表中的一个",
|
||||
(platformId) => {
|
||||
const recommendedSize = getRecommendedSize(platformId);
|
||||
const spec = getPlatformSpec(platformId);
|
||||
|
||||
expect(recommendedSize).toBeDefined();
|
||||
expect(spec!.sizes).toContainEqual(recommendedSize);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("平台规范单元测试", () => {
|
||||
describe("allPlatformSpecs", () => {
|
||||
it("应该包含 4 个平台规范", () => {
|
||||
expect(allPlatformSpecs).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("每个平台规范应该有必要字段", () => {
|
||||
allPlatformSpecs.forEach((spec) => {
|
||||
expect(spec.id).toBeDefined();
|
||||
expect(spec.name).toBeDefined();
|
||||
expect(spec.description).toBeDefined();
|
||||
expect(spec.sizes).toBeDefined();
|
||||
expect(spec.fileSpec).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("xiaohongshuSpec", () => {
|
||||
it("应该有正确的 ID 和名称", () => {
|
||||
expect(xiaohongshuSpec.id).toBe("xiaohongshu");
|
||||
expect(xiaohongshuSpec.name).toBe("小红书");
|
||||
});
|
||||
|
||||
it("应该有推荐的 3:4 尺寸", () => {
|
||||
const recommended = xiaohongshuSpec.sizes.find((s) => s.recommended);
|
||||
expect(recommended).toBeDefined();
|
||||
expect(recommended!.aspectRatio).toBe("3:4");
|
||||
expect(recommended!.width).toBe(1080);
|
||||
expect(recommended!.height).toBe(1440);
|
||||
});
|
||||
|
||||
it("应该有安全区域定义", () => {
|
||||
expect(xiaohongshuSpec.safeZone).toBeDefined();
|
||||
expect(xiaohongshuSpec.safeZone!.top).toBeGreaterThan(0);
|
||||
expect(xiaohongshuSpec.safeZone!.bottom).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wechatSpec", () => {
|
||||
it("应该有正确的 ID 和名称", () => {
|
||||
expect(wechatSpec.id).toBe("wechat");
|
||||
expect(wechatSpec.name).toBe("微信");
|
||||
});
|
||||
|
||||
it("应该支持公众号封面尺寸", () => {
|
||||
const coverSize = wechatSpec.sizes.find((s) =>
|
||||
s.name.includes("公众号封面"),
|
||||
);
|
||||
expect(coverSize).toBeDefined();
|
||||
expect(coverSize!.aspectRatio).toBe("2.35:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("taobaoSpec", () => {
|
||||
it("应该有正确的 ID 和名称", () => {
|
||||
expect(taobaoSpec.id).toBe("taobao");
|
||||
expect(taobaoSpec.name).toBe("淘宝");
|
||||
});
|
||||
|
||||
it("应该有商品主图尺寸", () => {
|
||||
const mainImage = taobaoSpec.sizes.find((s) =>
|
||||
s.name.includes("商品主图"),
|
||||
);
|
||||
expect(mainImage).toBeDefined();
|
||||
expect(mainImage!.aspectRatio).toBe("1:1");
|
||||
});
|
||||
|
||||
it("文件大小限制应该是 3MB", () => {
|
||||
expect(taobaoSpec.fileSpec.maxSizeKB).toBe(3072);
|
||||
});
|
||||
});
|
||||
|
||||
describe("douyinSpec", () => {
|
||||
it("应该有正确的 ID 和名称", () => {
|
||||
expect(douyinSpec.id).toBe("douyin");
|
||||
expect(douyinSpec.name).toBe("抖音");
|
||||
});
|
||||
|
||||
it("应该有 9:16 竖版视频封面", () => {
|
||||
const verticalCover = douyinSpec.sizes.find(
|
||||
(s) => s.aspectRatio === "9:16" && s.recommended,
|
||||
);
|
||||
expect(verticalCover).toBeDefined();
|
||||
expect(verticalCover!.width).toBe(1080);
|
||||
expect(verticalCover!.height).toBe(1920);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformSpec", () => {
|
||||
it("应该返回正确的平台规范", () => {
|
||||
expect(getPlatformSpec("xiaohongshu")).toBe(xiaohongshuSpec);
|
||||
expect(getPlatformSpec("wechat")).toBe(wechatSpec);
|
||||
expect(getPlatformSpec("taobao")).toBe(taobaoSpec);
|
||||
expect(getPlatformSpec("douyin")).toBe(douyinSpec);
|
||||
});
|
||||
|
||||
it("应该对未知平台返回 undefined", () => {
|
||||
expect(getPlatformSpec("unknown" as PlatformId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecommendedSize", () => {
|
||||
it("应该返回标记为推荐的尺寸", () => {
|
||||
const size = getRecommendedSize("xiaohongshu");
|
||||
expect(size).toBeDefined();
|
||||
expect(size!.recommended).toBe(true);
|
||||
});
|
||||
|
||||
it("如果没有推荐尺寸,应该返回第一个尺寸", () => {
|
||||
// 所有平台都有推荐尺寸,这里测试逻辑
|
||||
const size = getRecommendedSize("wechat");
|
||||
expect(size).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFileCompliance", () => {
|
||||
it("应该通过有效的文件", () => {
|
||||
const result = checkFileCompliance("xiaohongshu", 1000, "png");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("应该拒绝过大的文件", () => {
|
||||
const result = checkFileCompliance("taobao", 5000, "png"); // 5MB > 3MB
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain("文件大小");
|
||||
});
|
||||
|
||||
it("应该拒绝不支持的格式", () => {
|
||||
const result = checkFileCompliance("taobao", 1000, "svg");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain("格式");
|
||||
});
|
||||
|
||||
it("应该对未知平台返回错误", () => {
|
||||
const result = checkFileCompliance("unknown" as PlatformId, 1000, "png");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("未知平台");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 平台规范模块导出
|
||||
* @module components/content-creator/canvas/poster/platforms
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
PlatformId,
|
||||
SizeSpec,
|
||||
SafeZone,
|
||||
FileSpec,
|
||||
TextSpec,
|
||||
PlatformSpec,
|
||||
ExportConfig,
|
||||
BatchExportConfig,
|
||||
ExportResult,
|
||||
} from "./types";
|
||||
|
||||
// 平台规范导出
|
||||
export { xiaohongshuSpec } from "./xiaohongshu";
|
||||
export { wechatSpec } from "./wechat";
|
||||
export { taobaoSpec } from "./taobao";
|
||||
export { douyinSpec } from "./douyin";
|
||||
|
||||
import { xiaohongshuSpec } from "./xiaohongshu";
|
||||
import { wechatSpec } from "./wechat";
|
||||
import { taobaoSpec } from "./taobao";
|
||||
import { douyinSpec } from "./douyin";
|
||||
import type { PlatformSpec, PlatformId } from "./types";
|
||||
|
||||
/**
|
||||
* 所有平台规范
|
||||
*/
|
||||
export const allPlatformSpecs: PlatformSpec[] = [
|
||||
xiaohongshuSpec,
|
||||
wechatSpec,
|
||||
taobaoSpec,
|
||||
douyinSpec,
|
||||
];
|
||||
|
||||
/**
|
||||
* 平台规范注册表
|
||||
*/
|
||||
export const platformSpecRegistry: Record<PlatformId, PlatformSpec> = {
|
||||
xiaohongshu: xiaohongshuSpec,
|
||||
wechat: wechatSpec,
|
||||
taobao: taobaoSpec,
|
||||
douyin: douyinSpec,
|
||||
} as Record<PlatformId, PlatformSpec>;
|
||||
|
||||
/**
|
||||
* 获取平台规范
|
||||
*
|
||||
* @param id - 平台 ID
|
||||
* @returns 平台规范
|
||||
*/
|
||||
export function getPlatformSpec(id: PlatformId): PlatformSpec | undefined {
|
||||
return platformSpecRegistry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台推荐尺寸
|
||||
*
|
||||
* @param id - 平台 ID
|
||||
* @returns 推荐尺寸
|
||||
*/
|
||||
export function getRecommendedSize(id: PlatformId) {
|
||||
const spec = getPlatformSpec(id);
|
||||
if (!spec) return null;
|
||||
return spec.sizes.find((s) => s.recommended) || spec.sizes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否符合平台规范
|
||||
*
|
||||
* @param id - 平台 ID
|
||||
* @param fileSize - 文件大小 (KB)
|
||||
* @param format - 文件格式
|
||||
* @returns 是否符合规范
|
||||
*/
|
||||
export function checkFileCompliance(
|
||||
id: PlatformId,
|
||||
fileSize: number,
|
||||
format: string,
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const spec = getPlatformSpec(id);
|
||||
if (!spec) return { valid: false, errors: ["未知平台"] };
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (fileSize > spec.fileSpec.maxSizeKB) {
|
||||
errors.push(`文件大小超出限制 (最大 ${spec.fileSpec.maxSizeKB / 1024}MB)`);
|
||||
}
|
||||
|
||||
if (!spec.fileSpec.formats.includes(format.toLowerCase())) {
|
||||
errors.push(`不支持的文件格式 (支持: ${spec.fileSpec.formats.join(", ")})`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @file taobao.ts
|
||||
* @description 淘宝平台规范
|
||||
* @module components/content-creator/canvas/poster/platforms/taobao
|
||||
*/
|
||||
|
||||
import type { PlatformSpec } from "./types";
|
||||
|
||||
/**
|
||||
* 淘宝平台规范
|
||||
*/
|
||||
export const taobaoSpec: PlatformSpec = {
|
||||
id: "taobao",
|
||||
name: "淘宝",
|
||||
icon: "taobao",
|
||||
description: "淘宝商品主图、详情页、店铺装修规范",
|
||||
sizes: [
|
||||
{
|
||||
name: "商品主图 1:1",
|
||||
width: 800,
|
||||
height: 800,
|
||||
aspectRatio: "1:1",
|
||||
usage: "商品主图,搜索结果和商品页展示",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: "商品主图高清",
|
||||
width: 1500,
|
||||
height: 1500,
|
||||
aspectRatio: "1:1",
|
||||
usage: "高清商品主图,支持放大查看",
|
||||
},
|
||||
{
|
||||
name: "详情页头图",
|
||||
width: 750,
|
||||
height: 1000,
|
||||
aspectRatio: "3:4",
|
||||
usage: "详情页顶部展示图",
|
||||
},
|
||||
{
|
||||
name: "详情页长图",
|
||||
width: 750,
|
||||
height: 0, // 高度不限
|
||||
aspectRatio: "自适应",
|
||||
usage: "详情页内容图,宽度固定750",
|
||||
},
|
||||
{
|
||||
name: "店铺首页横幅",
|
||||
width: 1920,
|
||||
height: 600,
|
||||
aspectRatio: "16:5",
|
||||
usage: "店铺首页轮播横幅",
|
||||
},
|
||||
{
|
||||
name: "店铺首页海报",
|
||||
width: 750,
|
||||
height: 560,
|
||||
aspectRatio: "约4:3",
|
||||
usage: "店铺首页活动海报",
|
||||
},
|
||||
{
|
||||
name: "直通车创意图",
|
||||
width: 800,
|
||||
height: 800,
|
||||
aspectRatio: "1:1",
|
||||
usage: "直通车推广创意图",
|
||||
},
|
||||
],
|
||||
safeZone: {
|
||||
top: 60,
|
||||
bottom: 60,
|
||||
left: 60,
|
||||
right: 60,
|
||||
description: "主图四周预留空间,避免被角标遮挡",
|
||||
},
|
||||
fileSpec: {
|
||||
formats: ["jpg", "png"],
|
||||
maxSizeKB: 3072, // 3MB
|
||||
recommendedDPI: 72,
|
||||
colorMode: "RGB",
|
||||
},
|
||||
textSpec: {
|
||||
minFontSize: 20,
|
||||
recommendedTitleSize: 40,
|
||||
recommendedBodySize: 28,
|
||||
lineHeightRatio: 1.4,
|
||||
},
|
||||
notes: [
|
||||
"主图不能有牛皮癣(过多促销文字)",
|
||||
"主图背景建议使用纯色或简洁背景",
|
||||
"详情页图片建议控制在15张以内",
|
||||
"避免使用极限词(最、第一等)",
|
||||
"主图会显示各种角标,预留空间",
|
||||
"建议主图文字占比不超过20%",
|
||||
],
|
||||
guideUrl: "https://seller.taobao.com",
|
||||
};
|
||||
|
||||
export default taobaoSpec;
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 平台规范类型定义
|
||||
* @module components/content-creator/canvas/poster/platforms/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* 平台 ID
|
||||
*/
|
||||
export type PlatformId =
|
||||
| "xiaohongshu"
|
||||
| "wechat"
|
||||
| "weibo"
|
||||
| "taobao"
|
||||
| "jd"
|
||||
| "pinduoduo"
|
||||
| "douyin"
|
||||
| "bilibili"
|
||||
| "instagram"
|
||||
| "custom";
|
||||
|
||||
/**
|
||||
* 尺寸规格
|
||||
*/
|
||||
export interface SizeSpec {
|
||||
/** 规格名称 */
|
||||
name: string;
|
||||
/** 宽度 */
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
/** 宽高比 */
|
||||
aspectRatio: string;
|
||||
/** 用途说明 */
|
||||
usage: string;
|
||||
/** 是否推荐 */
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全区域
|
||||
*/
|
||||
export interface SafeZone {
|
||||
/** 顶部安全距离 */
|
||||
top: number;
|
||||
/** 底部安全距离 */
|
||||
bottom: number;
|
||||
/** 左侧安全距离 */
|
||||
left: number;
|
||||
/** 右侧安全距离 */
|
||||
right: number;
|
||||
/** 说明 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件规格
|
||||
*/
|
||||
export interface FileSpec {
|
||||
/** 支持的格式 */
|
||||
formats: string[];
|
||||
/** 最大文件大小 (KB) */
|
||||
maxSizeKB: number;
|
||||
/** 推荐 DPI */
|
||||
recommendedDPI?: number;
|
||||
/** 色彩模式 */
|
||||
colorMode: "RGB" | "CMYK" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字规范
|
||||
*/
|
||||
export interface TextSpec {
|
||||
/** 最小字号 */
|
||||
minFontSize: number;
|
||||
/** 推荐标题字号 */
|
||||
recommendedTitleSize: number;
|
||||
/** 推荐正文字号 */
|
||||
recommendedBodySize: number;
|
||||
/** 行高建议 */
|
||||
lineHeightRatio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台规范
|
||||
*/
|
||||
export interface PlatformSpec {
|
||||
/** 平台 ID */
|
||||
id: PlatformId;
|
||||
/** 平台名称 */
|
||||
name: string;
|
||||
/** 平台图标 */
|
||||
icon?: string;
|
||||
/** 平台描述 */
|
||||
description: string;
|
||||
/** 尺寸规格列表 */
|
||||
sizes: SizeSpec[];
|
||||
/** 安全区域 */
|
||||
safeZone?: SafeZone;
|
||||
/** 文件规格 */
|
||||
fileSpec: FileSpec;
|
||||
/** 文字规范 */
|
||||
textSpec?: TextSpec;
|
||||
/** 特殊注意事项 */
|
||||
notes?: string[];
|
||||
/** 平台链接 */
|
||||
guideUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出配置
|
||||
*/
|
||||
export interface ExportConfig {
|
||||
/** 目标平台 */
|
||||
platform: PlatformId;
|
||||
/** 选择的尺寸规格 */
|
||||
sizeSpec: SizeSpec;
|
||||
/** 文件格式 */
|
||||
format: string;
|
||||
/** 图片质量 (0-100) */
|
||||
quality: number;
|
||||
/** 是否显示安全区域 */
|
||||
showSafeZone?: boolean;
|
||||
/** 自定义文件名 */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导出配置
|
||||
*/
|
||||
export interface BatchExportConfig {
|
||||
/** 导出配置列表 */
|
||||
configs: ExportConfig[];
|
||||
/** 输出目录 */
|
||||
outputDir?: string;
|
||||
/** 文件名前缀 */
|
||||
filenamePrefix?: string;
|
||||
/** 是否压缩打包 */
|
||||
compress?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出结果
|
||||
*/
|
||||
export interface ExportResult {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 文件路径 */
|
||||
filePath?: string;
|
||||
/** 文件大小 (bytes) */
|
||||
fileSize?: number;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 平台 ID */
|
||||
platform: PlatformId;
|
||||
/** 尺寸规格名称 */
|
||||
sizeName: string;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file wechat.ts
|
||||
* @description 微信平台规范
|
||||
* @module components/content-creator/canvas/poster/platforms/wechat
|
||||
*/
|
||||
|
||||
import type { PlatformSpec } from "./types";
|
||||
|
||||
/**
|
||||
* 微信平台规范
|
||||
*/
|
||||
export const wechatSpec: PlatformSpec = {
|
||||
id: "wechat",
|
||||
name: "微信",
|
||||
icon: "wechat",
|
||||
description: "微信公众号、朋友圈、视频号规范",
|
||||
sizes: [
|
||||
{
|
||||
name: "公众号封面 2.35:1",
|
||||
width: 900,
|
||||
height: 383,
|
||||
aspectRatio: "2.35:1",
|
||||
usage: "公众号文章封面图,在订阅号消息列表显示",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: "公众号次图 1:1",
|
||||
width: 200,
|
||||
height: 200,
|
||||
aspectRatio: "1:1",
|
||||
usage: "公众号文章次条封面",
|
||||
},
|
||||
{
|
||||
name: "公众号正文图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "公众号文章内配图,方形最佳",
|
||||
},
|
||||
{
|
||||
name: "朋友圈图片",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "朋友圈分享图片,方形显示最完整",
|
||||
},
|
||||
{
|
||||
name: "视频号封面 16:9",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
aspectRatio: "16:9",
|
||||
usage: "视频号横版视频封面",
|
||||
},
|
||||
{
|
||||
name: "视频号封面 9:16",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
aspectRatio: "9:16",
|
||||
usage: "视频号竖版视频封面",
|
||||
},
|
||||
{
|
||||
name: "小程序分享图",
|
||||
width: 520,
|
||||
height: 416,
|
||||
aspectRatio: "5:4",
|
||||
usage: "小程序分享卡片图片",
|
||||
},
|
||||
],
|
||||
safeZone: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 20,
|
||||
right: 20,
|
||||
description: "公众号封面两侧可能被裁切,重要内容居中",
|
||||
},
|
||||
fileSpec: {
|
||||
formats: ["jpg", "png", "gif"],
|
||||
maxSizeKB: 10240, // 10MB
|
||||
recommendedDPI: 72,
|
||||
colorMode: "RGB",
|
||||
},
|
||||
textSpec: {
|
||||
minFontSize: 14,
|
||||
recommendedTitleSize: 36,
|
||||
recommendedBodySize: 24,
|
||||
lineHeightRatio: 1.6,
|
||||
},
|
||||
notes: [
|
||||
"公众号封面图会被裁切,重要信息放中间",
|
||||
"朋友圈图片建议不超过9张",
|
||||
"GIF 图片大小限制较严格",
|
||||
"视频号封面建议使用高清图片",
|
||||
"分享卡片图片会被压缩,避免使用小字",
|
||||
],
|
||||
guideUrl: "https://mp.weixin.qq.com",
|
||||
};
|
||||
|
||||
export default wechatSpec;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file xiaohongshu.ts
|
||||
* @description 小红书平台规范
|
||||
* @module components/content-creator/canvas/poster/platforms/xiaohongshu
|
||||
*/
|
||||
|
||||
import type { PlatformSpec } from "./types";
|
||||
|
||||
/**
|
||||
* 小红书平台规范
|
||||
*/
|
||||
export const xiaohongshuSpec: PlatformSpec = {
|
||||
id: "xiaohongshu",
|
||||
name: "小红书",
|
||||
icon: "xiaohongshu",
|
||||
description: "小红书图文笔记和视频封面规范",
|
||||
sizes: [
|
||||
{
|
||||
name: "竖版笔记 3:4",
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
aspectRatio: "3:4",
|
||||
usage: "最常用的笔记封面尺寸,适合大多数内容",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: "方形笔记 1:1",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "方形图片,适合产品展示和对比图",
|
||||
},
|
||||
{
|
||||
name: "横版笔记 4:3",
|
||||
width: 1440,
|
||||
height: 1080,
|
||||
aspectRatio: "4:3",
|
||||
usage: "横版图片,适合风景和场景展示",
|
||||
},
|
||||
{
|
||||
name: "全屏竖版 9:16",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
aspectRatio: "9:16",
|
||||
usage: "全屏竖版,适合视频封面和沉浸式内容",
|
||||
},
|
||||
{
|
||||
name: "长图笔记 2:3",
|
||||
width: 1080,
|
||||
height: 1620,
|
||||
aspectRatio: "2:3",
|
||||
usage: "长图格式,适合信息量较大的内容",
|
||||
},
|
||||
],
|
||||
safeZone: {
|
||||
top: 120,
|
||||
bottom: 180,
|
||||
left: 40,
|
||||
right: 40,
|
||||
description: "顶部预留标题区域,底部预留互动按钮区域",
|
||||
},
|
||||
fileSpec: {
|
||||
formats: ["jpg", "png", "webp"],
|
||||
maxSizeKB: 20480, // 20MB
|
||||
recommendedDPI: 72,
|
||||
colorMode: "RGB",
|
||||
},
|
||||
textSpec: {
|
||||
minFontSize: 24,
|
||||
recommendedTitleSize: 48,
|
||||
recommendedBodySize: 32,
|
||||
lineHeightRatio: 1.5,
|
||||
},
|
||||
notes: [
|
||||
"首图决定点击率,建议使用高质量图片",
|
||||
"标题文字不要太小,确保在信息流中清晰可见",
|
||||
"避免在底部放置重要信息,会被互动按钮遮挡",
|
||||
"建议使用明亮、高饱和度的配色",
|
||||
"多图笔记建议保持风格统一",
|
||||
],
|
||||
guideUrl: "https://creator.xiaohongshu.com",
|
||||
};
|
||||
|
||||
export default xiaohongshuSpec;
|
||||
@@ -25,3 +25,28 @@ export {
|
||||
type ImageStyle,
|
||||
type ImageFilter,
|
||||
} from "./styleUtils";
|
||||
|
||||
// 安全区域工具
|
||||
export {
|
||||
createSafeZoneOverlay,
|
||||
checkSafeZone,
|
||||
showPlatformSafeZone,
|
||||
hideSafeZone,
|
||||
} from "./safeZone";
|
||||
export type {
|
||||
SafeZoneDisplayOptions,
|
||||
SafeZoneCheckResult,
|
||||
SafeZoneViolation,
|
||||
} from "./safeZone";
|
||||
|
||||
// 智能裁切工具
|
||||
export {
|
||||
calculateSmartCrop,
|
||||
applyCropToCanvas,
|
||||
previewCrop,
|
||||
} from "./smartCrop";
|
||||
export type {
|
||||
CropRegion,
|
||||
SmartCropOptions,
|
||||
SmartCropResult,
|
||||
} from "./smartCrop";
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file 安全区域工具测试
|
||||
* @description 测试安全区域功能的正确性
|
||||
* @module components/content-creator/canvas/poster/utils/safeZone.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { checkSafeZone } from "./safeZone";
|
||||
import type { SafeZone } from "../platforms/types";
|
||||
import type { fabric } from "fabric";
|
||||
|
||||
/**
|
||||
* SafeZone 生成器
|
||||
*/
|
||||
const safeZoneArb = fc.record({
|
||||
top: fc.integer({ min: 0, max: 500 }),
|
||||
bottom: fc.integer({ min: 0, max: 500 }),
|
||||
left: fc.integer({ min: 0, max: 200 }),
|
||||
right: fc.integer({ min: 0, max: 200 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock Fabric.js Canvas
|
||||
*/
|
||||
function createMockCanvas(
|
||||
width: number,
|
||||
height: number,
|
||||
objects: Array<{
|
||||
name?: string;
|
||||
type: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
selectable?: boolean;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
getWidth: () => width,
|
||||
getHeight: () => height,
|
||||
getObjects: () =>
|
||||
objects.map((obj) => ({
|
||||
...obj,
|
||||
type: obj.type,
|
||||
selectable: obj.selectable ?? true,
|
||||
getBoundingRect: () => ({
|
||||
left: obj.left,
|
||||
top: obj.top,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
}),
|
||||
})),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
renderAll: vi.fn(),
|
||||
} as unknown as fabric.Canvas;
|
||||
}
|
||||
|
||||
describe("安全区域属性测试", () => {
|
||||
/**
|
||||
* Property: 空画布应该没有违规
|
||||
*/
|
||||
test.prop([safeZoneArb])("空画布应该没有安全区域违规", (safeZone) => {
|
||||
const canvas = createMockCanvas(1080, 1440, []);
|
||||
const result = checkSafeZone(canvas, safeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(true);
|
||||
expect(result.violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property: 安全区域内的元素不应该有违规
|
||||
*/
|
||||
test.prop([safeZoneArb])("安全区域内的元素不应该有违规", (safeZone) => {
|
||||
const canvasWidth = 1080;
|
||||
const canvasHeight = 1440;
|
||||
|
||||
// 创建一个在安全区域内的元素
|
||||
const elementWidth = 100;
|
||||
const elementHeight = 100;
|
||||
const safeLeft = safeZone.left + 10;
|
||||
const safeTop = safeZone.top + 10;
|
||||
|
||||
// 确保元素在安全区域内
|
||||
if (
|
||||
safeLeft + elementWidth > canvasWidth - safeZone.right ||
|
||||
safeTop + elementHeight > canvasHeight - safeZone.bottom
|
||||
) {
|
||||
return; // 跳过无效的测试用例
|
||||
}
|
||||
|
||||
const canvas = createMockCanvas(canvasWidth, canvasHeight, [
|
||||
{
|
||||
name: "safe-element",
|
||||
type: "rect",
|
||||
left: safeLeft,
|
||||
top: safeTop,
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, safeZone);
|
||||
expect(result.isInSafeZone).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("安全区域单元测试", () => {
|
||||
describe("checkSafeZone", () => {
|
||||
const defaultSafeZone: SafeZone = {
|
||||
top: 120,
|
||||
bottom: 180,
|
||||
left: 40,
|
||||
right: 40,
|
||||
};
|
||||
|
||||
it("应该检测顶部违规", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "top-element",
|
||||
type: "textbox",
|
||||
left: 100,
|
||||
top: 50, // 在安全区域外
|
||||
width: 200,
|
||||
height: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0].violatedZone).toBe("top");
|
||||
expect(result.violations[0].overflowAmount).toBe(70); // 120 - 50
|
||||
});
|
||||
|
||||
it("应该检测底部违规", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "bottom-element",
|
||||
type: "textbox",
|
||||
left: 100,
|
||||
top: 1300, // 1300 + 200 = 1500 > 1440 - 180 = 1260
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(false);
|
||||
expect(result.violations.some((v) => v.violatedZone === "bottom")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("应该检测左侧违规", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "left-element",
|
||||
type: "textbox",
|
||||
left: 10, // 在安全区域外
|
||||
top: 200,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(false);
|
||||
expect(result.violations.some((v) => v.violatedZone === "left")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("应该检测右侧违规", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "right-element",
|
||||
type: "textbox",
|
||||
left: 1000, // 1000 + 100 = 1100 > 1080 - 40 = 1040
|
||||
top: 200,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(false);
|
||||
expect(result.violations.some((v) => v.violatedZone === "right")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("应该检测多个违规", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "corner-element",
|
||||
type: "textbox",
|
||||
left: 10, // 左侧违规
|
||||
top: 50, // 顶部违规
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(false);
|
||||
expect(result.violations.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("应该忽略不可选择的元素", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "background",
|
||||
type: "rect",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
selectable: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(true);
|
||||
expect(result.violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("应该忽略安全区域覆盖层", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "safeZoneOverlay",
|
||||
type: "group",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.isInSafeZone).toBe(true);
|
||||
});
|
||||
|
||||
it("应该正确报告违规元素信息", () => {
|
||||
const canvas = createMockCanvas(1080, 1440, [
|
||||
{
|
||||
name: "my-text",
|
||||
type: "textbox",
|
||||
left: 10,
|
||||
top: 200,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = checkSafeZone(canvas, defaultSafeZone);
|
||||
|
||||
expect(result.violations[0].elementName).toBe("my-text");
|
||||
expect(result.violations[0].elementType).toBe("textbox");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file safeZone.ts
|
||||
* @description 安全区域工具
|
||||
* @module components/content-creator/canvas/poster/utils/safeZone
|
||||
*/
|
||||
|
||||
import { fabric } from "fabric";
|
||||
import type { SafeZone, PlatformId } from "../platforms/types";
|
||||
import { getPlatformSpec } from "../platforms";
|
||||
|
||||
/**
|
||||
* 安全区域显示选项
|
||||
*/
|
||||
export interface SafeZoneDisplayOptions {
|
||||
/** 边框颜色 */
|
||||
strokeColor?: string;
|
||||
/** 边框宽度 */
|
||||
strokeWidth?: number;
|
||||
/** 填充颜色 */
|
||||
fillColor?: string;
|
||||
/** 填充透明度 */
|
||||
fillOpacity?: number;
|
||||
/** 是否显示标签 */
|
||||
showLabels?: boolean;
|
||||
/** 标签字体大小 */
|
||||
labelFontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全区域检查结果
|
||||
*/
|
||||
export interface SafeZoneCheckResult {
|
||||
/** 是否在安全区域内 */
|
||||
isInSafeZone: boolean;
|
||||
/** 违规元素列表 */
|
||||
violations: SafeZoneViolation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全区域违规
|
||||
*/
|
||||
export interface SafeZoneViolation {
|
||||
/** 元素名称 */
|
||||
elementName: string;
|
||||
/** 元素类型 */
|
||||
elementType: string;
|
||||
/** 违规区域 */
|
||||
violatedZone: "top" | "bottom" | "left" | "right";
|
||||
/** 超出距离 */
|
||||
overflowAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全区域遮罩
|
||||
*
|
||||
* @param canvas - Fabric.js 画布
|
||||
* @param safeZone - 安全区域配置
|
||||
* @param options - 显示选项
|
||||
* @returns 安全区域对象组
|
||||
*/
|
||||
export function createSafeZoneOverlay(
|
||||
canvas: fabric.Canvas,
|
||||
safeZone: SafeZone,
|
||||
options: SafeZoneDisplayOptions = {},
|
||||
): fabric.Group {
|
||||
const {
|
||||
strokeColor = "#ff6b6b",
|
||||
strokeWidth = 2,
|
||||
fillColor = "#ff6b6b",
|
||||
fillOpacity = 0.1,
|
||||
showLabels = true,
|
||||
labelFontSize = 12,
|
||||
} = options;
|
||||
|
||||
const canvasWidth = canvas.getWidth();
|
||||
const canvasHeight = canvas.getHeight();
|
||||
const objects: fabric.Object[] = [];
|
||||
|
||||
// 顶部危险区域
|
||||
if (safeZone.top > 0) {
|
||||
const topRect = new fabric.Rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: canvasWidth,
|
||||
height: safeZone.top,
|
||||
fill: fillColor,
|
||||
opacity: fillOpacity,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(topRect);
|
||||
|
||||
if (showLabels) {
|
||||
const topLabel = new fabric.Text("顶部危险区", {
|
||||
left: canvasWidth / 2,
|
||||
top: safeZone.top / 2,
|
||||
fontSize: labelFontSize,
|
||||
fill: strokeColor,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(topLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// 底部危险区域
|
||||
if (safeZone.bottom > 0) {
|
||||
const bottomRect = new fabric.Rect({
|
||||
left: 0,
|
||||
top: canvasHeight - safeZone.bottom,
|
||||
width: canvasWidth,
|
||||
height: safeZone.bottom,
|
||||
fill: fillColor,
|
||||
opacity: fillOpacity,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(bottomRect);
|
||||
|
||||
if (showLabels) {
|
||||
const bottomLabel = new fabric.Text("底部危险区", {
|
||||
left: canvasWidth / 2,
|
||||
top: canvasHeight - safeZone.bottom / 2,
|
||||
fontSize: labelFontSize,
|
||||
fill: strokeColor,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(bottomLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// 左侧危险区域
|
||||
if (safeZone.left > 0) {
|
||||
const leftRect = new fabric.Rect({
|
||||
left: 0,
|
||||
top: safeZone.top,
|
||||
width: safeZone.left,
|
||||
height: canvasHeight - safeZone.top - safeZone.bottom,
|
||||
fill: fillColor,
|
||||
opacity: fillOpacity,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(leftRect);
|
||||
}
|
||||
|
||||
// 右侧危险区域
|
||||
if (safeZone.right > 0) {
|
||||
const rightRect = new fabric.Rect({
|
||||
left: canvasWidth - safeZone.right,
|
||||
top: safeZone.top,
|
||||
width: safeZone.right,
|
||||
height: canvasHeight - safeZone.top - safeZone.bottom,
|
||||
fill: fillColor,
|
||||
opacity: fillOpacity,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(rightRect);
|
||||
}
|
||||
|
||||
// 安全区域边框
|
||||
const safeRect = new fabric.Rect({
|
||||
left: safeZone.left,
|
||||
top: safeZone.top,
|
||||
width: canvasWidth - safeZone.left - safeZone.right,
|
||||
height: canvasHeight - safeZone.top - safeZone.bottom,
|
||||
fill: "transparent",
|
||||
stroke: strokeColor,
|
||||
strokeWidth: strokeWidth,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
objects.push(safeRect);
|
||||
|
||||
// 创建组
|
||||
const group = new fabric.Group(objects, {
|
||||
selectable: false,
|
||||
evented: false,
|
||||
name: "safeZoneOverlay",
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否在安全区域内
|
||||
*
|
||||
* @param canvas - Fabric.js 画布
|
||||
* @param safeZone - 安全区域配置
|
||||
* @returns 检查结果
|
||||
*/
|
||||
export function checkSafeZone(
|
||||
canvas: fabric.Canvas,
|
||||
safeZone: SafeZone,
|
||||
): SafeZoneCheckResult {
|
||||
const canvasWidth = canvas.getWidth();
|
||||
const canvasHeight = canvas.getHeight();
|
||||
const violations: SafeZoneViolation[] = [];
|
||||
|
||||
const objects = canvas
|
||||
.getObjects()
|
||||
.filter(
|
||||
(obj) => obj.name !== "safeZoneOverlay" && obj.selectable !== false,
|
||||
);
|
||||
|
||||
for (const obj of objects) {
|
||||
const bounds = obj.getBoundingRect();
|
||||
const objName =
|
||||
(obj as fabric.Object & { name?: string }).name || obj.type || "未命名";
|
||||
|
||||
// 检查顶部
|
||||
if (bounds.top < safeZone.top) {
|
||||
violations.push({
|
||||
elementName: objName,
|
||||
elementType: obj.type || "unknown",
|
||||
violatedZone: "top",
|
||||
overflowAmount: safeZone.top - bounds.top,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查底部
|
||||
if (bounds.top + bounds.height > canvasHeight - safeZone.bottom) {
|
||||
violations.push({
|
||||
elementName: objName,
|
||||
elementType: obj.type || "unknown",
|
||||
violatedZone: "bottom",
|
||||
overflowAmount:
|
||||
bounds.top + bounds.height - (canvasHeight - safeZone.bottom),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查左侧
|
||||
if (bounds.left < safeZone.left) {
|
||||
violations.push({
|
||||
elementName: objName,
|
||||
elementType: obj.type || "unknown",
|
||||
violatedZone: "left",
|
||||
overflowAmount: safeZone.left - bounds.left,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查右侧
|
||||
if (bounds.left + bounds.width > canvasWidth - safeZone.right) {
|
||||
violations.push({
|
||||
elementName: objName,
|
||||
elementType: obj.type || "unknown",
|
||||
violatedZone: "right",
|
||||
overflowAmount:
|
||||
bounds.left + bounds.width - (canvasWidth - safeZone.right),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isInSafeZone: violations.length === 0,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据平台显示安全区域
|
||||
*
|
||||
* @param canvas - Fabric.js 画布
|
||||
* @param platformId - 平台 ID
|
||||
* @param options - 显示选项
|
||||
* @returns 安全区域对象组,如果平台没有安全区域则返回 null
|
||||
*/
|
||||
export function showPlatformSafeZone(
|
||||
canvas: fabric.Canvas,
|
||||
platformId: PlatformId,
|
||||
options?: SafeZoneDisplayOptions,
|
||||
): fabric.Group | null {
|
||||
const spec = getPlatformSpec(platformId);
|
||||
if (!spec?.safeZone) return null;
|
||||
|
||||
// 移除已有的安全区域
|
||||
hideSafeZone(canvas);
|
||||
|
||||
// 创建新的安全区域
|
||||
const overlay = createSafeZoneOverlay(canvas, spec.safeZone, options);
|
||||
canvas.add(overlay);
|
||||
canvas.renderAll();
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏安全区域
|
||||
*
|
||||
* @param canvas - Fabric.js 画布
|
||||
*/
|
||||
export function hideSafeZone(canvas: fabric.Canvas): void {
|
||||
const overlay = canvas
|
||||
.getObjects()
|
||||
.find(
|
||||
(obj) =>
|
||||
(obj as fabric.Object & { name?: string }).name === "safeZoneOverlay",
|
||||
);
|
||||
if (overlay) {
|
||||
canvas.remove(overlay);
|
||||
canvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createSafeZoneOverlay,
|
||||
checkSafeZone,
|
||||
showPlatformSafeZone,
|
||||
hideSafeZone,
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file 智能裁切工具测试
|
||||
* @description 测试智能裁切功能的正确性
|
||||
* @module components/content-creator/canvas/poster/utils/smartCrop.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { calculateSmartCrop } from "./smartCrop";
|
||||
|
||||
/**
|
||||
* 尺寸生成器
|
||||
*/
|
||||
const sizeArb = fc.integer({ min: 100, max: 4000 });
|
||||
|
||||
/**
|
||||
* SizeSpec 生成器
|
||||
*/
|
||||
const sizeSpecArb = fc.record({
|
||||
name: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
width: sizeArb,
|
||||
height: sizeArb,
|
||||
aspectRatio: fc.string(),
|
||||
usage: fc.string(),
|
||||
});
|
||||
|
||||
describe("智能裁切属性测试", () => {
|
||||
/**
|
||||
* Property: 裁切区域应该在源图像范围内
|
||||
*/
|
||||
test.prop([sizeArb, sizeArb, sizeSpecArb])(
|
||||
"裁切区域应该在源图像范围内",
|
||||
(sourceWidth, sourceHeight, targetSpec) => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec,
|
||||
});
|
||||
|
||||
expect(result.cropRegion.x).toBeGreaterThanOrEqual(0);
|
||||
expect(result.cropRegion.y).toBeGreaterThanOrEqual(0);
|
||||
expect(result.cropRegion.x + result.cropRegion.width).toBeLessThanOrEqual(
|
||||
sourceWidth,
|
||||
);
|
||||
expect(
|
||||
result.cropRegion.y + result.cropRegion.height,
|
||||
).toBeLessThanOrEqual(sourceHeight);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: 裁切区域的宽高比应该与目标一致
|
||||
*/
|
||||
test.prop([sizeArb, sizeArb, sizeSpecArb])(
|
||||
"裁切区域的宽高比应该与目标一致",
|
||||
(sourceWidth, sourceHeight, targetSpec) => {
|
||||
// 跳过高度为 0 的情况
|
||||
if (targetSpec.height === 0) return;
|
||||
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec,
|
||||
});
|
||||
|
||||
const targetRatio = targetSpec.width / targetSpec.height;
|
||||
const cropRatio = result.cropRegion.width / result.cropRegion.height;
|
||||
|
||||
// 允许小误差
|
||||
expect(Math.abs(cropRatio - targetRatio)).toBeLessThan(0.01);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: 缩放比例应该是正数
|
||||
*/
|
||||
test.prop([sizeArb, sizeArb, sizeSpecArb])(
|
||||
"缩放比例应该是正数",
|
||||
(sourceWidth, sourceHeight, targetSpec) => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec,
|
||||
});
|
||||
|
||||
expect(result.scale).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: 相同宽高比不需要裁切
|
||||
*/
|
||||
it("相同宽高比不需要裁切", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1080,
|
||||
sourceHeight: 1440,
|
||||
targetSpec: {
|
||||
name: "3:4",
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
aspectRatio: "3:4",
|
||||
usage: "test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.needsCrop).toBe(false);
|
||||
expect(result.cropRegion.x).toBe(0);
|
||||
expect(result.cropRegion.y).toBe(0);
|
||||
expect(result.cropRegion.width).toBe(1080);
|
||||
expect(result.cropRegion.height).toBe(1440);
|
||||
});
|
||||
});
|
||||
|
||||
describe("智能裁切单元测试", () => {
|
||||
describe("calculateSmartCrop", () => {
|
||||
it("应该正确处理宽图裁切为方图", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.needsCrop).toBe(true);
|
||||
expect(result.cropRegion.width).toBe(1080);
|
||||
expect(result.cropRegion.height).toBe(1080);
|
||||
// 居中裁切
|
||||
expect(result.cropRegion.x).toBe(420); // (1920 - 1080) / 2
|
||||
expect(result.cropRegion.y).toBe(0);
|
||||
});
|
||||
|
||||
it("应该正确处理高图裁切为方图", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1080,
|
||||
sourceHeight: 1920,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.needsCrop).toBe(true);
|
||||
expect(result.cropRegion.width).toBe(1080);
|
||||
expect(result.cropRegion.height).toBe(1080);
|
||||
expect(result.cropRegion.x).toBe(0);
|
||||
});
|
||||
|
||||
it("应该支持 center 策略", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
strategy: "center",
|
||||
});
|
||||
|
||||
// 居中裁切
|
||||
expect(result.cropRegion.x).toBe(420);
|
||||
});
|
||||
|
||||
it("应该支持 focus 策略", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
strategy: "focus",
|
||||
focusPoint: { x: 0.2, y: 0.5 }, // 焦点在左侧
|
||||
});
|
||||
|
||||
// 焦点裁切应该偏左
|
||||
expect(result.cropRegion.x).toBeLessThan(420);
|
||||
});
|
||||
|
||||
it("应该支持 smart 策略", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1080,
|
||||
sourceHeight: 1920,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
strategy: "smart",
|
||||
});
|
||||
|
||||
// smart 策略对高图应该偏上裁切
|
||||
expect(result.cropRegion.y).toBeLessThan((1920 - 1080) / 2);
|
||||
});
|
||||
|
||||
it("应该生成安全区域警告", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
targetSpec: {
|
||||
name: "方图",
|
||||
width: 500,
|
||||
height: 500,
|
||||
aspectRatio: "1:1",
|
||||
usage: "test",
|
||||
},
|
||||
safeZone: {
|
||||
top: 100,
|
||||
bottom: 100,
|
||||
left: 800, // 大于裁切量,会触发警告
|
||||
right: 800,
|
||||
},
|
||||
});
|
||||
|
||||
// 由于裁切量较大,应该有警告
|
||||
expect(result.needsCrop).toBe(true);
|
||||
});
|
||||
|
||||
it("应该正确计算缩放比例", () => {
|
||||
const result = calculateSmartCrop({
|
||||
sourceWidth: 2160,
|
||||
sourceHeight: 2880,
|
||||
targetSpec: {
|
||||
name: "3:4",
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
aspectRatio: "3:4",
|
||||
usage: "test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.scale).toBe(0.5); // 1080 / 2160
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @file smartCrop.ts
|
||||
* @description 智能裁切工具
|
||||
* @module components/content-creator/canvas/poster/utils/smartCrop
|
||||
*/
|
||||
|
||||
import type { SizeSpec, SafeZone } from "../platforms/types";
|
||||
import type { fabric } from "fabric";
|
||||
|
||||
/**
|
||||
* 裁切区域
|
||||
*/
|
||||
export interface CropRegion {
|
||||
/** 左上角 X */
|
||||
x: number;
|
||||
/** 左上角 Y */
|
||||
y: number;
|
||||
/** 宽度 */
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切选项
|
||||
*/
|
||||
export interface SmartCropOptions {
|
||||
/** 源尺寸 */
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
/** 目标尺寸 */
|
||||
targetSpec: SizeSpec;
|
||||
/** 安全区域 */
|
||||
safeZone?: SafeZone;
|
||||
/** 焦点位置 (0-1) */
|
||||
focusPoint?: { x: number; y: number };
|
||||
/** 裁切策略 */
|
||||
strategy?: "center" | "focus" | "smart";
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切结果
|
||||
*/
|
||||
export interface SmartCropResult {
|
||||
/** 裁切区域 */
|
||||
cropRegion: CropRegion;
|
||||
/** 缩放比例 */
|
||||
scale: number;
|
||||
/** 是否需要裁切 */
|
||||
needsCrop: boolean;
|
||||
/** 安全区域警告 */
|
||||
safeZoneWarnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算智能裁切区域
|
||||
*
|
||||
* @param options - 裁切选项
|
||||
* @returns 裁切结果
|
||||
*/
|
||||
export function calculateSmartCrop(options: SmartCropOptions): SmartCropResult {
|
||||
const {
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec,
|
||||
safeZone,
|
||||
focusPoint = { x: 0.5, y: 0.5 },
|
||||
strategy = "center",
|
||||
} = options;
|
||||
|
||||
const targetWidth = targetSpec.width;
|
||||
const targetHeight = targetSpec.height;
|
||||
|
||||
// 计算宽高比
|
||||
const sourceRatio = sourceWidth / sourceHeight;
|
||||
const targetRatio = targetWidth / targetHeight;
|
||||
|
||||
let cropRegion: CropRegion;
|
||||
let scale: number;
|
||||
let needsCrop = false;
|
||||
|
||||
if (Math.abs(sourceRatio - targetRatio) < 0.01) {
|
||||
// 宽高比相同,直接缩放
|
||||
scale = targetWidth / sourceWidth;
|
||||
cropRegion = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: sourceWidth,
|
||||
height: sourceHeight,
|
||||
};
|
||||
} else if (sourceRatio > targetRatio) {
|
||||
// 源图更宽,需要裁切左右
|
||||
needsCrop = true;
|
||||
const newWidth = sourceHeight * targetRatio;
|
||||
scale = targetWidth / newWidth;
|
||||
|
||||
let x: number;
|
||||
if (strategy === "center") {
|
||||
x = (sourceWidth - newWidth) / 2;
|
||||
} else if (strategy === "focus") {
|
||||
x = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
sourceWidth - newWidth,
|
||||
focusPoint.x * sourceWidth - newWidth / 2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// smart: 默认居中
|
||||
x = (sourceWidth - newWidth) / 2;
|
||||
}
|
||||
|
||||
cropRegion = {
|
||||
x,
|
||||
y: 0,
|
||||
width: newWidth,
|
||||
height: sourceHeight,
|
||||
};
|
||||
} else {
|
||||
// 源图更高,需要裁切上下
|
||||
needsCrop = true;
|
||||
const newHeight = sourceWidth / targetRatio;
|
||||
scale = targetHeight / newHeight;
|
||||
|
||||
let y: number;
|
||||
if (strategy === "center") {
|
||||
y = (sourceHeight - newHeight) / 2;
|
||||
} else if (strategy === "focus") {
|
||||
y = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
sourceHeight - newHeight,
|
||||
focusPoint.y * sourceHeight - newHeight / 2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// smart: 偏上裁切(人物通常在上半部分)
|
||||
y = (sourceHeight - newHeight) / 3;
|
||||
}
|
||||
|
||||
cropRegion = {
|
||||
x: 0,
|
||||
y,
|
||||
width: sourceWidth,
|
||||
height: newHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查安全区域
|
||||
const safeZoneWarnings: string[] = [];
|
||||
if (safeZone && needsCrop) {
|
||||
const scaledSafeZone = {
|
||||
top: safeZone.top / scale,
|
||||
bottom: safeZone.bottom / scale,
|
||||
left: safeZone.left / scale,
|
||||
right: safeZone.right / scale,
|
||||
};
|
||||
|
||||
if (cropRegion.x > scaledSafeZone.left) {
|
||||
safeZoneWarnings.push("左侧内容可能被裁切");
|
||||
}
|
||||
if (sourceWidth - cropRegion.x - cropRegion.width > scaledSafeZone.right) {
|
||||
safeZoneWarnings.push("右侧内容可能被裁切");
|
||||
}
|
||||
if (cropRegion.y > scaledSafeZone.top) {
|
||||
safeZoneWarnings.push("顶部内容可能被裁切");
|
||||
}
|
||||
if (
|
||||
sourceHeight - cropRegion.y - cropRegion.height >
|
||||
scaledSafeZone.bottom
|
||||
) {
|
||||
safeZoneWarnings.push("底部内容可能被裁切");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cropRegion,
|
||||
scale,
|
||||
needsCrop,
|
||||
safeZoneWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用裁切到画布
|
||||
*
|
||||
* @param canvas - Fabric.js 画布
|
||||
* @param cropResult - 裁切结果
|
||||
* @param targetSpec - 目标尺寸
|
||||
*/
|
||||
export function applyCropToCanvas(
|
||||
canvas: fabric.Canvas,
|
||||
cropResult: SmartCropResult,
|
||||
targetSpec: SizeSpec,
|
||||
): void {
|
||||
const { cropRegion, scale } = cropResult;
|
||||
|
||||
// 设置画布视口
|
||||
canvas.setViewportTransform([
|
||||
scale,
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
-cropRegion.x * scale,
|
||||
-cropRegion.y * scale,
|
||||
]);
|
||||
|
||||
// 更新画布尺寸
|
||||
canvas.setWidth(targetSpec.width);
|
||||
canvas.setHeight(targetSpec.height);
|
||||
canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览裁切效果
|
||||
*
|
||||
* @param sourceDataUrl - 源图片 DataURL
|
||||
* @param cropResult - 裁切结果
|
||||
* @param targetSpec - 目标尺寸
|
||||
* @returns 预览图片 DataURL
|
||||
*/
|
||||
export async function previewCrop(
|
||||
sourceDataUrl: string,
|
||||
cropResult: SmartCropResult,
|
||||
targetSpec: SizeSpec,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetSpec.width;
|
||||
canvas.height = targetSpec.height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("无法创建 canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { cropRegion, scale: _scale } = cropResult;
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropRegion.x,
|
||||
cropRegion.y,
|
||||
cropRegion.width,
|
||||
cropRegion.height,
|
||||
0,
|
||||
0,
|
||||
targetSpec.width,
|
||||
targetSpec.height,
|
||||
);
|
||||
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
img.onerror = () => reject(new Error("图片加载失败"));
|
||||
img.src = sourceDataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
calculateSmartCrop,
|
||||
applyCropToCanvas,
|
||||
previewCrop,
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @file ImageGallery.tsx
|
||||
* @description 图片素材库组件,用于海报设计中的图片素材管理和选择
|
||||
* @module components/content-creator/material/ImageGallery
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { usePosterMaterial } from "@/hooks/usePosterMaterial";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
SearchIcon,
|
||||
ImageIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
} from "lucide-react";
|
||||
import type { PosterMaterial, ImageCategory } from "@/types/poster-material";
|
||||
import {
|
||||
IMAGE_CATEGORY_NAMES,
|
||||
IMAGE_CATEGORY_ICONS,
|
||||
} from "@/types/poster-material";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ImageGalleryProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 选中的素材 ID 列表 */
|
||||
selectedIds?: string[];
|
||||
/** 是否允许多选 */
|
||||
multiple?: boolean;
|
||||
/** 选择变化回调 */
|
||||
onSelect?: (materials: PosterMaterial[]) => void;
|
||||
/** 双击素材回调(用于直接应用到画布) */
|
||||
onDoubleClick?: (material: PosterMaterial) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 最大高度 */
|
||||
maxHeight?: string | number;
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES: ImageCategory[] = [
|
||||
"background",
|
||||
"product",
|
||||
"person",
|
||||
"decoration",
|
||||
"texture",
|
||||
"other",
|
||||
];
|
||||
|
||||
/**
|
||||
* 图片素材库组件
|
||||
*
|
||||
* 提供图片素材的浏览、筛选和选择功能。
|
||||
*/
|
||||
export function ImageGallery({
|
||||
projectId,
|
||||
selectedIds = [],
|
||||
multiple = false,
|
||||
onSelect,
|
||||
onDoubleClick,
|
||||
className,
|
||||
maxHeight = "400px",
|
||||
}: ImageGalleryProps) {
|
||||
const { materials, loading, filter, setFilter } = usePosterMaterial(
|
||||
projectId,
|
||||
{ type: "image" },
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
useState<ImageCategory | null>(null);
|
||||
|
||||
// 本地筛选
|
||||
const filteredMaterials = useMemo(() => {
|
||||
let result = materials;
|
||||
|
||||
// 按分类筛选
|
||||
if (selectedCategory) {
|
||||
result = result.filter(
|
||||
(m) => m.metadata?.imageCategory === selectedCategory,
|
||||
);
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.description?.toLowerCase().includes(query) ||
|
||||
m.tags.some((tag) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [materials, selectedCategory, searchQuery]);
|
||||
|
||||
const handleCategoryFilter = (category: ImageCategory | null) => {
|
||||
setSelectedCategory(category);
|
||||
setFilter({ ...filter, imageCategory: category || undefined });
|
||||
};
|
||||
|
||||
const handleSelect = (material: PosterMaterial) => {
|
||||
if (!onSelect) return;
|
||||
|
||||
if (multiple) {
|
||||
const isSelected = selectedIds.includes(material.id);
|
||||
if (isSelected) {
|
||||
onSelect(
|
||||
materials.filter(
|
||||
(m) => selectedIds.includes(m.id) && m.id !== material.id,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
onSelect([
|
||||
...materials.filter((m) => selectedIds.includes(m.id)),
|
||||
material,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
onSelect([material]);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center h-64", className)}>
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索图片..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视图切换 */}
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-r-none"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-l-none"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类筛选 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant={!selectedCategory ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => handleCategoryFilter(null)}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{ALL_CATEGORIES.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => handleCategoryFilter(category)}
|
||||
>
|
||||
<span className="mr-1">{IMAGE_CATEGORY_ICONS[category]}</span>
|
||||
{IMAGE_CATEGORY_NAMES[category]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<ScrollArea style={{ maxHeight }} className="rounded-md border">
|
||||
{filteredMaterials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ImageIcon className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm">暂无图片素材</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{filteredMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={cn(
|
||||
"relative aspect-square rounded-md overflow-hidden cursor-pointer border-2 transition-all",
|
||||
isSelected(material.id)
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-transparent hover:border-muted-foreground/30",
|
||||
)}
|
||||
onClick={() => handleSelect(material)}
|
||||
onDoubleClick={() => onDoubleClick?.(material)}
|
||||
>
|
||||
{/* 缩略图或占位符 */}
|
||||
{material.metadata?.thumbnail ? (
|
||||
<img
|
||||
src={material.metadata.thumbnail}
|
||||
alt={material.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : material.filePath ? (
|
||||
<img
|
||||
src={`asset://localhost/${material.filePath}`}
|
||||
alt={material.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中标记 */}
|
||||
{isSelected(material.id) && (
|
||||
<div className="absolute top-1 right-1 bg-primary text-primary-foreground rounded-full p-0.5">
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类标签 */}
|
||||
{material.metadata?.imageCategory && (
|
||||
<div className="absolute bottom-1 left-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1 py-0 bg-background/80"
|
||||
>
|
||||
{
|
||||
IMAGE_CATEGORY_ICONS[
|
||||
material.metadata.imageCategory as ImageCategory
|
||||
]
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 cursor-pointer transition-colors",
|
||||
isSelected(material.id)
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => handleSelect(material)}
|
||||
onDoubleClick={() => onDoubleClick?.(material)}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
||||
{material.metadata?.thumbnail ? (
|
||||
<img
|
||||
src={material.metadata.thumbnail}
|
||||
alt={material.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{material.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{material.metadata?.imageCategory && (
|
||||
<span>
|
||||
{
|
||||
IMAGE_CATEGORY_NAMES[
|
||||
material.metadata.imageCategory as ImageCategory
|
||||
]
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{material.metadata?.width && material.metadata?.height && (
|
||||
<span>
|
||||
{material.metadata.width}×{material.metadata.height}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选中标记 */}
|
||||
{isSelected(material.id) && (
|
||||
<CheckIcon className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {filteredMaterials.length} 张图片
|
||||
{selectedIds.length > 0 && `,已选 ${selectedIds.length} 张`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageGallery;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 素材组件导出
|
||||
* @module components/content-creator/material
|
||||
*/
|
||||
|
||||
export { ImageGallery } from "./ImageGallery";
|
||||
export type { ImageGalleryProps } from "./ImageGallery";
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* @file PosterWorkflowPanel.tsx
|
||||
* @description 海报工作流面板组件
|
||||
* @module components/content-creator/workflows/poster/PosterWorkflowPanel
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
RotateCcw,
|
||||
Play,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePosterWorkflow } from "@/hooks/usePosterWorkflow";
|
||||
import type {
|
||||
WorkflowTemplate,
|
||||
WorkflowStep,
|
||||
WorkflowStepStatus,
|
||||
InputField,
|
||||
WorkflowContext,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 工作流面板属性
|
||||
*/
|
||||
export interface PosterWorkflowPanelProps {
|
||||
/** 项目 ID */
|
||||
projectId?: string;
|
||||
/** 品牌人设 ID */
|
||||
brandPersonaId?: string;
|
||||
/** 画布 JSON */
|
||||
canvasJson?: Record<string, unknown>;
|
||||
/** 步骤完成回调 */
|
||||
onStepComplete?: (stepId: string, result: unknown) => void;
|
||||
/** 工作流完成回调 */
|
||||
onWorkflowComplete?: (results: Map<string, unknown>) => void;
|
||||
/** 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤状态图标
|
||||
*/
|
||||
function StepStatusIcon({ status }: { status: WorkflowStepStatus }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
||||
case "active":
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
case "skipped":
|
||||
return <SkipForward className="h-5 w-5 text-gray-400" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <Circle className="h-5 w-5 text-gray-300" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流选择器
|
||||
*/
|
||||
function WorkflowSelector({
|
||||
workflows,
|
||||
onSelect,
|
||||
}: {
|
||||
workflows: WorkflowTemplate[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{workflows.map((workflow) => (
|
||||
<Card
|
||||
key={workflow.id}
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => onSelect(workflow.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">{workflow.name}</CardTitle>
|
||||
<CardDescription>{workflow.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{workflow.tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{workflow.steps.length} 个步骤
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤导航
|
||||
*/
|
||||
function StepNavigation({
|
||||
steps,
|
||||
currentIndex,
|
||||
getStatus,
|
||||
onStepClick,
|
||||
}: {
|
||||
steps: WorkflowStep[];
|
||||
currentIndex: number;
|
||||
getStatus: (stepId: string) => WorkflowStepStatus;
|
||||
onStepClick: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStatus(step.id);
|
||||
const isActive = index === currentIndex;
|
||||
const isClickable =
|
||||
status === "completed" || status === "skipped" || isActive;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-px w-8 flex-shrink-0",
|
||||
status === "completed" || status === "skipped"
|
||||
? "bg-green-500"
|
||||
: "bg-gray-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg transition-colors flex-shrink-0",
|
||||
isActive && "bg-primary/10 border border-primary",
|
||||
!isActive && isClickable && "hover:bg-muted",
|
||||
!isClickable && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => isClickable && onStepClick(index)}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<StepStatusIcon status={status} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isActive && "text-primary",
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入字段渲染
|
||||
*/
|
||||
function InputFieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: InputField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
placeholder={field.placeholder}
|
||||
value={(value as string) || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
value={(value as string) || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={(value as string) || ""}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={(value as string) || "#000000"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-10 h-10 rounded border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={(value as string) || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
placeholder={field.placeholder}
|
||||
value={(value as string) || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤内容面板
|
||||
*/
|
||||
function StepContentPanel({
|
||||
step,
|
||||
formData,
|
||||
onFormChange,
|
||||
onExecute,
|
||||
onSkip,
|
||||
isExecuting,
|
||||
}: {
|
||||
step: WorkflowStep;
|
||||
formData: Record<string, unknown>;
|
||||
onFormChange: (key: string, value: unknown) => void;
|
||||
onExecute: () => void;
|
||||
onSkip: () => void;
|
||||
isExecuting: boolean;
|
||||
}) {
|
||||
const hasRequiredFields = step.inputFields?.some((f) => f.required) ?? false;
|
||||
const allRequiredFilled =
|
||||
!hasRequiredFields ||
|
||||
(step.inputFields
|
||||
?.filter((f) => f.required)
|
||||
.every((f) => formData[f.key]) ??
|
||||
true);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{step.name}</CardTitle>
|
||||
<CardDescription>{step.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step.inputFields && step.inputFields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{step.inputFields.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
<InputFieldRenderer
|
||||
field={field}
|
||||
value={formData[field.key]}
|
||||
onChange={(value) => onFormChange(field.key, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{step.optional && (
|
||||
<Button variant="ghost" size="sm" onClick={onSkip}>
|
||||
<SkipForward className="h-4 w-4 mr-1" />
|
||||
跳过此步骤
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onExecute}
|
||||
disabled={isExecuting || !allRequiredFilled}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
执行中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
执行此步骤
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 海报工作流面板
|
||||
*/
|
||||
export function PosterWorkflowPanel({
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
canvasJson,
|
||||
onStepComplete,
|
||||
onWorkflowComplete,
|
||||
className,
|
||||
}: PosterWorkflowPanelProps) {
|
||||
const {
|
||||
currentWorkflow,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
isExecuting,
|
||||
isCompleted,
|
||||
progress,
|
||||
availableWorkflows,
|
||||
startWorkflow,
|
||||
executeCurrentStep,
|
||||
skipCurrentStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
goToStep,
|
||||
resetWorkflow,
|
||||
getStepStatus,
|
||||
} = usePosterWorkflow();
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 处理表单变更
|
||||
const handleFormChange = useCallback((key: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// 执行当前步骤
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!currentStep) return;
|
||||
|
||||
const context: WorkflowContext = {
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
userInput: JSON.stringify(formData),
|
||||
canvasJson,
|
||||
extra: formData,
|
||||
};
|
||||
|
||||
const result = await executeCurrentStep(context);
|
||||
if (result) {
|
||||
onStepComplete?.(currentStep.id, result);
|
||||
// 清空表单,准备下一步
|
||||
setFormData({});
|
||||
goToNextStep();
|
||||
}
|
||||
}, [
|
||||
currentStep,
|
||||
projectId,
|
||||
brandPersonaId,
|
||||
formData,
|
||||
canvasJson,
|
||||
executeCurrentStep,
|
||||
onStepComplete,
|
||||
goToNextStep,
|
||||
]);
|
||||
|
||||
// 跳过步骤
|
||||
const handleSkip = useCallback(() => {
|
||||
skipCurrentStep();
|
||||
setFormData({});
|
||||
}, [skipCurrentStep]);
|
||||
|
||||
// 工作流完成处理
|
||||
React.useEffect(() => {
|
||||
if (isCompleted && onWorkflowComplete) {
|
||||
// 收集所有结果
|
||||
const results = new Map<string, unknown>();
|
||||
onWorkflowComplete(results);
|
||||
}
|
||||
}, [isCompleted, onWorkflowComplete]);
|
||||
|
||||
// 未选择工作流时显示选择器
|
||||
if (!currentWorkflow) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">选择工作流</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
选择一个适合您需求的工作流模板开始创作
|
||||
</p>
|
||||
</div>
|
||||
<WorkflowSelector
|
||||
workflows={availableWorkflows}
|
||||
onSelect={startWorkflow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 工作流完成
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold">工作流已完成</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
您已完成 {currentWorkflow.name} 的所有步骤
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-4">
|
||||
<Button variant="outline" onClick={resetWorkflow}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
重新开始
|
||||
</Button>
|
||||
<Button onClick={() => startWorkflow("")}>选择其他工作流</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 工作流标题和进度 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{currentWorkflow.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
步骤 {currentStepIndex + 1} / {currentWorkflow.steps.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32">
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤导航 */}
|
||||
<ScrollArea className="w-full">
|
||||
<StepNavigation
|
||||
steps={currentWorkflow.steps}
|
||||
currentIndex={currentStepIndex}
|
||||
getStatus={getStepStatus}
|
||||
onStepClick={goToStep}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 当前步骤内容 */}
|
||||
{currentStep && (
|
||||
<StepContentPanel
|
||||
step={currentStep}
|
||||
formData={formData}
|
||||
onFormChange={handleFormChange}
|
||||
onExecute={handleExecute}
|
||||
onSkip={handleSkip}
|
||||
isExecuting={isExecuting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToPreviousStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetWorkflow}>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToNextStep}
|
||||
disabled={
|
||||
getStepStatus(currentStep?.id || "") !== "completed" &&
|
||||
getStepStatus(currentStep?.id || "") !== "skipped"
|
||||
}
|
||||
>
|
||||
下一步
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PosterWorkflowPanel;
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @file brand-image.ts
|
||||
* @description 品牌形象海报工作流
|
||||
* @module components/content-creator/workflows/poster/brand-image
|
||||
*/
|
||||
|
||||
import type { WorkflowTemplate, WorkflowStep } from "./types";
|
||||
|
||||
/**
|
||||
* 品牌形象工作流步骤
|
||||
*/
|
||||
const brandSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "requirement",
|
||||
name: "品牌分析",
|
||||
description: "分析品牌调性、核心价值和传播目标",
|
||||
agentId: "requirement",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 40,
|
||||
inputFields: [
|
||||
{
|
||||
key: "brandName",
|
||||
label: "品牌名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "brandSlogan",
|
||||
label: "品牌口号",
|
||||
type: "text",
|
||||
placeholder: "品牌 Slogan 或核心理念",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "brandTone",
|
||||
label: "品牌调性",
|
||||
type: "select",
|
||||
options: [
|
||||
"高端奢华",
|
||||
"年轻活力",
|
||||
"专业可靠",
|
||||
"温馨亲切",
|
||||
"科技前沿",
|
||||
"自然环保",
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "communicationGoal",
|
||||
label: "传播目标",
|
||||
type: "select",
|
||||
options: ["品牌认知", "产品推广", "活动宣传", "理念传达", "招商加盟"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "targetAudience",
|
||||
label: "目标受众",
|
||||
type: "textarea",
|
||||
placeholder: "描述目标受众的特征、兴趣和需求",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "style",
|
||||
name: "风格定义",
|
||||
description: "基于品牌 VI 定义视觉风格",
|
||||
agentId: "style",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 30,
|
||||
inputFields: [
|
||||
{
|
||||
key: "primaryColor",
|
||||
label: "品牌主色",
|
||||
type: "color",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "secondaryColor",
|
||||
label: "品牌辅色",
|
||||
type: "color",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "fontStyle",
|
||||
label: "字体风格",
|
||||
type: "select",
|
||||
options: ["现代简约", "经典衬线", "手写温暖", "粗犷力量", "优雅纤细"],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "visualStyle",
|
||||
label: "视觉风格",
|
||||
type: "select",
|
||||
options: ["扁平简约", "立体质感", "插画风格", "摄影写实", "几何抽象"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
name: "布局设计",
|
||||
description: "设计体现品牌气质的海报布局",
|
||||
agentId: "layout",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 50,
|
||||
inputFields: [
|
||||
{
|
||||
key: "layoutStyle",
|
||||
label: "布局风格",
|
||||
type: "select",
|
||||
options: ["中心对称", "黄金分割", "留白极简", "动态平衡", "网格系统"],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "emphasis",
|
||||
label: "视觉重点",
|
||||
type: "select",
|
||||
options: ["品牌标志", "核心文案", "产品展示", "人物形象", "场景氛围"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
name: "内容整合",
|
||||
description: "整合品牌元素和核心信息",
|
||||
agentId: "content",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 35,
|
||||
inputFields: [
|
||||
{
|
||||
key: "logo",
|
||||
label: "品牌 Logo",
|
||||
type: "image",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "mainVisual",
|
||||
label: "主视觉图",
|
||||
type: "image",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "headline",
|
||||
label: "主标题",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "subheadline",
|
||||
label: "副标题",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "bodyText",
|
||||
label: "正文内容",
|
||||
type: "textarea",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "refine",
|
||||
name: "精细调整",
|
||||
description: "优化品牌一致性和视觉平衡",
|
||||
agentId: "refine",
|
||||
status: "pending",
|
||||
optional: true,
|
||||
estimatedDuration: 25,
|
||||
inputFields: [],
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "规范输出",
|
||||
description: "按品牌规范导出多种应用场景",
|
||||
agentId: "export",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 20,
|
||||
inputFields: [
|
||||
{
|
||||
key: "exportFormats",
|
||||
label: "导出场景",
|
||||
type: "multiselect",
|
||||
options: ["印刷物料", "户外广告", "线上展示", "社交媒体", "PPT/提案"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "colorMode",
|
||||
label: "色彩模式",
|
||||
type: "select",
|
||||
options: ["RGB (屏幕显示)", "CMYK (印刷)", "两者都要"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 品牌形象工作流模板
|
||||
*/
|
||||
export const brandImageWorkflow: WorkflowTemplate = {
|
||||
id: "brand-image",
|
||||
name: "品牌形象海报",
|
||||
description: "打造专业的品牌形象海报,传递品牌价值和调性",
|
||||
icon: "award",
|
||||
category: "branding",
|
||||
steps: brandSteps,
|
||||
defaultContext: {
|
||||
industry: "branding",
|
||||
style: "professional",
|
||||
colorTone: "brand-consistent",
|
||||
},
|
||||
suggestedDimensions: [
|
||||
{ width: 1080, height: 1920, name: "竖版海报" },
|
||||
{ width: 1920, height: 1080, name: "横版海报" },
|
||||
{ width: 1080, height: 1080, name: "方形海报" },
|
||||
{ width: 2480, height: 3508, name: "A4 印刷" },
|
||||
],
|
||||
tags: ["品牌", "形象", "VI", "宣传"],
|
||||
};
|
||||
|
||||
export default brandImageWorkflow;
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file ecommerce-promo.ts
|
||||
* @description 电商促销海报工作流
|
||||
* @module components/content-creator/workflows/poster/ecommerce-promo
|
||||
*/
|
||||
|
||||
import type { WorkflowTemplate, WorkflowStep } from "./types";
|
||||
|
||||
/**
|
||||
* 电商促销工作流步骤
|
||||
*/
|
||||
const ecommerceSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "requirement",
|
||||
name: "需求分析",
|
||||
description: "分析促销活动类型、目标人群和营销目标",
|
||||
agentId: "requirement",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 30,
|
||||
inputFields: [
|
||||
{
|
||||
key: "promoType",
|
||||
label: "促销类型",
|
||||
type: "select",
|
||||
options: ["限时折扣", "满减活动", "新品上市", "清仓特卖", "节日促销"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "targetAudience",
|
||||
label: "目标人群",
|
||||
type: "text",
|
||||
placeholder: "例如:18-35岁女性,关注时尚美妆",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "productInfo",
|
||||
label: "商品信息",
|
||||
type: "textarea",
|
||||
placeholder: "商品名称、原价、促销价、卖点等",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "deadline",
|
||||
label: "活动时间",
|
||||
type: "text",
|
||||
placeholder: "例如:2024.1.1-1.7",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "style",
|
||||
name: "风格推荐",
|
||||
description: "基于促销类型推荐合适的视觉风格",
|
||||
agentId: "style",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 20,
|
||||
inputFields: [
|
||||
{
|
||||
key: "stylePreference",
|
||||
label: "风格偏好",
|
||||
type: "select",
|
||||
options: ["活力促销", "高端质感", "简约清新", "热闘氛围", "品牌调性"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
name: "布局生成",
|
||||
description: "生成适合电商平台的海报布局",
|
||||
agentId: "layout",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 45,
|
||||
inputFields: [
|
||||
{
|
||||
key: "platform",
|
||||
label: "目标平台",
|
||||
type: "select",
|
||||
options: ["淘宝", "京东", "拼多多", "小红书", "抖音"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "layoutType",
|
||||
label: "布局类型",
|
||||
type: "select",
|
||||
options: ["商品主图", "活动海报", "详情页头图", "店铺首页"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
name: "内容填充",
|
||||
description: "填充商品图片、促销文案和价格信息",
|
||||
agentId: "content",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 30,
|
||||
inputFields: [
|
||||
{
|
||||
key: "productImage",
|
||||
label: "商品图片",
|
||||
type: "image",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "originalPrice",
|
||||
label: "原价",
|
||||
type: "text",
|
||||
placeholder: "¥199",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "promoPrice",
|
||||
label: "促销价",
|
||||
type: "text",
|
||||
placeholder: "¥99",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "ctaText",
|
||||
label: "行动号召",
|
||||
type: "text",
|
||||
placeholder: "立即抢购",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "refine",
|
||||
name: "优化调整",
|
||||
description: "优化视觉层次和转化引导",
|
||||
agentId: "refine",
|
||||
status: "pending",
|
||||
optional: true,
|
||||
estimatedDuration: 20,
|
||||
inputFields: [],
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "导出发布",
|
||||
description: "导出适合各电商平台的尺寸格式",
|
||||
agentId: "export",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 15,
|
||||
inputFields: [
|
||||
{
|
||||
key: "exportPlatforms",
|
||||
label: "导出平台",
|
||||
type: "multiselect",
|
||||
options: ["淘宝主图", "京东主图", "拼多多主图", "详情页", "店铺装修"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 电商促销工作流模板
|
||||
*/
|
||||
export const ecommercePromoWorkflow: WorkflowTemplate = {
|
||||
id: "ecommerce-promo",
|
||||
name: "电商促销海报",
|
||||
description: "快速制作高转化的电商促销海报,支持多平台尺寸",
|
||||
icon: "shopping-cart",
|
||||
category: "ecommerce",
|
||||
steps: ecommerceSteps,
|
||||
defaultContext: {
|
||||
industry: "ecommerce",
|
||||
style: "promotional",
|
||||
colorTone: "vibrant",
|
||||
},
|
||||
suggestedDimensions: [
|
||||
{ width: 800, height: 800, name: "淘宝主图" },
|
||||
{ width: 750, height: 1000, name: "详情页头图" },
|
||||
{ width: 800, height: 1200, name: "活动海报" },
|
||||
],
|
||||
tags: ["电商", "促销", "卖货", "转化"],
|
||||
};
|
||||
|
||||
export default ecommercePromoWorkflow;
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file 工作流模板测试
|
||||
* @description 测试工作流模板的正确性
|
||||
* @module components/content-creator/workflows/poster/index.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
allWorkflowTemplates,
|
||||
workflowTemplateRegistry,
|
||||
getWorkflowTemplate,
|
||||
getWorkflowsByCategory,
|
||||
searchWorkflows,
|
||||
ecommercePromoWorkflow,
|
||||
brandImageWorkflow,
|
||||
socialMediaWorkflow,
|
||||
} from "./index";
|
||||
import type { WorkflowCategory } from "./types";
|
||||
|
||||
/**
|
||||
* WorkflowCategory 生成器
|
||||
*/
|
||||
const workflowCategoryArb = fc.constantFrom<WorkflowCategory>(
|
||||
"ecommerce",
|
||||
"branding",
|
||||
"social",
|
||||
"event",
|
||||
"education",
|
||||
"custom",
|
||||
);
|
||||
|
||||
describe("工作流模板属性测试", () => {
|
||||
/**
|
||||
* Property: 所有工作流模板应该有有效的步骤列表
|
||||
*/
|
||||
it("所有工作流模板应该至少有一个步骤", () => {
|
||||
allWorkflowTemplates.forEach((template) => {
|
||||
expect(template.steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property: 所有步骤应该有有效的 agentId
|
||||
*/
|
||||
it("所有步骤应该有有效的 agentId", () => {
|
||||
const validAgentIds = [
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
];
|
||||
|
||||
allWorkflowTemplates.forEach((template) => {
|
||||
template.steps.forEach((step) => {
|
||||
expect(validAgentIds).toContain(step.agentId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Property: getWorkflowsByCategory 应该返回正确分类的工作流
|
||||
*/
|
||||
test.prop([workflowCategoryArb])(
|
||||
"getWorkflowsByCategory 应该返回正确分类的工作流",
|
||||
(category) => {
|
||||
const workflows = getWorkflowsByCategory(category);
|
||||
workflows.forEach((workflow) => {
|
||||
expect(workflow.category).toBe(category);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("工作流模板单元测试", () => {
|
||||
describe("allWorkflowTemplates", () => {
|
||||
it("应该包含 3 个工作流模板", () => {
|
||||
expect(allWorkflowTemplates).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("每个模板应该有唯一的 ID", () => {
|
||||
const ids = allWorkflowTemplates.map((t) => t.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ecommercePromoWorkflow", () => {
|
||||
it("应该有正确的 ID 和分类", () => {
|
||||
expect(ecommercePromoWorkflow.id).toBe("ecommerce-promo");
|
||||
expect(ecommercePromoWorkflow.category).toBe("ecommerce");
|
||||
});
|
||||
|
||||
it("应该有 6 个步骤", () => {
|
||||
expect(ecommercePromoWorkflow.steps).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("步骤顺序应该正确", () => {
|
||||
const stepIds = ecommercePromoWorkflow.steps.map((s) => s.agentId);
|
||||
expect(stepIds).toEqual([
|
||||
"requirement",
|
||||
"style",
|
||||
"layout",
|
||||
"content",
|
||||
"refine",
|
||||
"export",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refine 步骤应该是可选的", () => {
|
||||
const refineStep = ecommercePromoWorkflow.steps.find(
|
||||
(s) => s.agentId === "refine",
|
||||
);
|
||||
expect(refineStep?.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("应该有建议尺寸", () => {
|
||||
expect(ecommercePromoWorkflow.suggestedDimensions).toBeDefined();
|
||||
expect(
|
||||
ecommercePromoWorkflow.suggestedDimensions!.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brandImageWorkflow", () => {
|
||||
it("应该有正确的 ID 和分类", () => {
|
||||
expect(brandImageWorkflow.id).toBe("brand-image");
|
||||
expect(brandImageWorkflow.category).toBe("branding");
|
||||
});
|
||||
|
||||
it("应该有 6 个步骤", () => {
|
||||
expect(brandImageWorkflow.steps).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("应该有品牌相关的输入字段", () => {
|
||||
const requirementStep = brandImageWorkflow.steps.find(
|
||||
(s) => s.agentId === "requirement",
|
||||
);
|
||||
const inputFields = requirementStep?.inputFields || [];
|
||||
const fieldKeys = inputFields.map((f) => f.key);
|
||||
|
||||
expect(fieldKeys).toContain("brandName");
|
||||
expect(fieldKeys).toContain("brandTone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("socialMediaWorkflow", () => {
|
||||
it("应该有正确的 ID 和分类", () => {
|
||||
expect(socialMediaWorkflow.id).toBe("social-media");
|
||||
expect(socialMediaWorkflow.category).toBe("social");
|
||||
});
|
||||
|
||||
it("应该有 6 个步骤", () => {
|
||||
expect(socialMediaWorkflow.steps).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("应该有平台选择字段", () => {
|
||||
const requirementStep = socialMediaWorkflow.steps.find(
|
||||
(s) => s.agentId === "requirement",
|
||||
);
|
||||
const platformField = requirementStep?.inputFields?.find(
|
||||
(f) => f.key === "platform",
|
||||
);
|
||||
|
||||
expect(platformField).toBeDefined();
|
||||
expect(platformField?.type).toBe("select");
|
||||
expect(platformField?.options).toContain("小红书");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkflowTemplate", () => {
|
||||
it("应该返回正确的工作流模板", () => {
|
||||
expect(getWorkflowTemplate("ecommerce-promo")).toBe(
|
||||
ecommercePromoWorkflow,
|
||||
);
|
||||
expect(getWorkflowTemplate("brand-image")).toBe(brandImageWorkflow);
|
||||
expect(getWorkflowTemplate("social-media")).toBe(socialMediaWorkflow);
|
||||
});
|
||||
|
||||
it("应该对未知 ID 返回 undefined", () => {
|
||||
expect(getWorkflowTemplate("unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkflowsByCategory", () => {
|
||||
it("应该返回电商分类的工作流", () => {
|
||||
const workflows = getWorkflowsByCategory("ecommerce");
|
||||
expect(workflows).toHaveLength(1);
|
||||
expect(workflows[0].id).toBe("ecommerce-promo");
|
||||
});
|
||||
|
||||
it("应该返回品牌分类的工作流", () => {
|
||||
const workflows = getWorkflowsByCategory("branding");
|
||||
expect(workflows).toHaveLength(1);
|
||||
expect(workflows[0].id).toBe("brand-image");
|
||||
});
|
||||
|
||||
it("应该返回社交分类的工作流", () => {
|
||||
const workflows = getWorkflowsByCategory("social");
|
||||
expect(workflows).toHaveLength(1);
|
||||
expect(workflows[0].id).toBe("social-media");
|
||||
});
|
||||
|
||||
it("应该对没有工作流的分类返回空数组", () => {
|
||||
const workflows = getWorkflowsByCategory("event");
|
||||
expect(workflows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchWorkflows", () => {
|
||||
it("应该通过名称搜索工作流", () => {
|
||||
const results = searchWorkflows("电商");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("ecommerce-promo");
|
||||
});
|
||||
|
||||
it("应该通过描述搜索工作流", () => {
|
||||
const results = searchWorkflows("品牌");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("应该通过标签搜索工作流", () => {
|
||||
const results = searchWorkflows("小红书");
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("应该对无匹配返回空数组", () => {
|
||||
const results = searchWorkflows("不存在的关键词xyz");
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("搜索应该不区分大小写", () => {
|
||||
const results1 = searchWorkflows("社交");
|
||||
const results2 = searchWorkflows("社交媒体");
|
||||
expect(results1.length).toBeGreaterThan(0);
|
||||
expect(results2.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflowTemplateRegistry", () => {
|
||||
it("应该包含所有工作流模板", () => {
|
||||
expect(Object.keys(workflowTemplateRegistry)).toHaveLength(3);
|
||||
expect(workflowTemplateRegistry["ecommerce-promo"]).toBeDefined();
|
||||
expect(workflowTemplateRegistry["brand-image"]).toBeDefined();
|
||||
expect(workflowTemplateRegistry["social-media"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 海报工作流模块导出
|
||||
* @module components/content-creator/workflows/poster
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
WorkflowStepStatus,
|
||||
InputFieldType,
|
||||
InputField,
|
||||
WorkflowStep,
|
||||
WorkflowPrompt,
|
||||
WorkflowStepState,
|
||||
WorkflowTemplate,
|
||||
WorkflowCategory,
|
||||
WorkflowConfig,
|
||||
WorkflowState,
|
||||
WorkflowEvent,
|
||||
WorkflowCallbacks,
|
||||
WorkflowContext,
|
||||
SuggestedDimension,
|
||||
} from "./types";
|
||||
|
||||
// 工作流模板导出
|
||||
export { ecommercePromoWorkflow } from "./ecommerce-promo";
|
||||
export { brandImageWorkflow } from "./brand-image";
|
||||
export { socialMediaWorkflow } from "./social-media";
|
||||
|
||||
// UI 组件导出
|
||||
export { PosterWorkflowPanel } from "./PosterWorkflowPanel";
|
||||
export type { PosterWorkflowPanelProps } from "./PosterWorkflowPanel";
|
||||
|
||||
import { ecommercePromoWorkflow } from "./ecommerce-promo";
|
||||
import { brandImageWorkflow } from "./brand-image";
|
||||
import { socialMediaWorkflow } from "./social-media";
|
||||
import type { WorkflowTemplate, WorkflowCategory } from "./types";
|
||||
|
||||
/**
|
||||
* 所有工作流模板
|
||||
*/
|
||||
export const allWorkflowTemplates: WorkflowTemplate[] = [
|
||||
ecommercePromoWorkflow,
|
||||
brandImageWorkflow,
|
||||
socialMediaWorkflow,
|
||||
];
|
||||
|
||||
/**
|
||||
* 工作流模板注册表
|
||||
*/
|
||||
export const workflowTemplateRegistry: Record<string, WorkflowTemplate> = {
|
||||
"ecommerce-promo": ecommercePromoWorkflow,
|
||||
"brand-image": brandImageWorkflow,
|
||||
"social-media": socialMediaWorkflow,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取工作流模板
|
||||
*
|
||||
* @param id - 工作流 ID
|
||||
* @returns 工作流模板
|
||||
*/
|
||||
export function getWorkflowTemplate(id: string): WorkflowTemplate | undefined {
|
||||
return workflowTemplateRegistry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分类获取工作流模板
|
||||
*
|
||||
* @param category - 工作流分类
|
||||
* @returns 工作流模板列表
|
||||
*/
|
||||
export function getWorkflowsByCategory(
|
||||
category: WorkflowCategory,
|
||||
): WorkflowTemplate[] {
|
||||
return allWorkflowTemplates.filter((w) => w.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索工作流模板
|
||||
*
|
||||
* @param query - 搜索关键词
|
||||
* @returns 匹配的工作流模板列表
|
||||
*/
|
||||
export function searchWorkflows(query: string): WorkflowTemplate[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allWorkflowTemplates.filter(
|
||||
(w) =>
|
||||
w.name.toLowerCase().includes(lowerQuery) ||
|
||||
w.description.toLowerCase().includes(lowerQuery) ||
|
||||
w.tags?.some((tag) => tag.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @file social-media.ts
|
||||
* @description 社交媒体海报工作流
|
||||
* @module components/content-creator/workflows/poster/social-media
|
||||
*/
|
||||
|
||||
import type { WorkflowTemplate, WorkflowStep } from "./types";
|
||||
|
||||
/**
|
||||
* 社交媒体工作流步骤
|
||||
*/
|
||||
const socialMediaSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "requirement",
|
||||
name: "内容策划",
|
||||
description: "确定发布平台、内容主题和传播目标",
|
||||
agentId: "requirement",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 25,
|
||||
inputFields: [
|
||||
{
|
||||
key: "platform",
|
||||
label: "发布平台",
|
||||
type: "select",
|
||||
options: ["小红书", "微信公众号", "微博", "抖音", "B站", "Instagram"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "contentType",
|
||||
label: "内容类型",
|
||||
type: "select",
|
||||
options: [
|
||||
"种草分享",
|
||||
"知识干货",
|
||||
"日常记录",
|
||||
"产品测评",
|
||||
"活动宣传",
|
||||
"热点借势",
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "topic",
|
||||
label: "内容主题",
|
||||
type: "text",
|
||||
placeholder: "简要描述你要发布的内容",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "tone",
|
||||
label: "内容调性",
|
||||
type: "select",
|
||||
options: ["专业权威", "轻松有趣", "温馨治愈", "酷炫潮流", "简约高级"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "style",
|
||||
name: "风格匹配",
|
||||
description: "匹配平台特性和用户偏好的视觉风格",
|
||||
agentId: "style",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 20,
|
||||
inputFields: [
|
||||
{
|
||||
key: "visualTrend",
|
||||
label: "视觉趋势",
|
||||
type: "select",
|
||||
options: [
|
||||
"杂志感",
|
||||
"ins风",
|
||||
"小清新",
|
||||
"赛博朋克",
|
||||
"复古怀旧",
|
||||
"极简主义",
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "colorMood",
|
||||
label: "色彩情绪",
|
||||
type: "select",
|
||||
options: ["明亮活泼", "低饱和高级", "黑金质感", "糖果色系", "大地色系"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
name: "排版布局",
|
||||
description: "生成适合社交平台阅读习惯的布局",
|
||||
agentId: "layout",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 35,
|
||||
inputFields: [
|
||||
{
|
||||
key: "layoutFormat",
|
||||
label: "布局格式",
|
||||
type: "select",
|
||||
options: ["单图封面", "多图轮播首图", "信息图表", "对比图", "步骤图"],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "textDensity",
|
||||
label: "文字密度",
|
||||
type: "select",
|
||||
options: ["少量标题", "适中图文", "信息丰富"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
name: "内容填充",
|
||||
description: "添加图片、文案和互动元素",
|
||||
agentId: "content",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 30,
|
||||
inputFields: [
|
||||
{
|
||||
key: "mainImage",
|
||||
label: "主图/素材",
|
||||
type: "image",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
label: "标题文案",
|
||||
type: "text",
|
||||
placeholder: "吸引眼球的标题",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "keyPoints",
|
||||
label: "核心要点",
|
||||
type: "textarea",
|
||||
placeholder: "每行一个要点,会自动排版",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "callToAction",
|
||||
label: "互动引导",
|
||||
type: "text",
|
||||
placeholder: "例如:点赞收藏不迷路",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "refine",
|
||||
name: "细节优化",
|
||||
description: "优化可读性和吸引力",
|
||||
agentId: "refine",
|
||||
status: "pending",
|
||||
optional: true,
|
||||
estimatedDuration: 15,
|
||||
inputFields: [],
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "平台适配",
|
||||
description: "导出符合各平台规范的尺寸",
|
||||
agentId: "export",
|
||||
status: "pending",
|
||||
optional: false,
|
||||
estimatedDuration: 10,
|
||||
inputFields: [
|
||||
{
|
||||
key: "exportPlatforms",
|
||||
label: "导出平台",
|
||||
type: "multiselect",
|
||||
options: ["小红书", "微信公众号", "微博", "抖音", "B站"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 社交媒体工作流模板
|
||||
*/
|
||||
export const socialMediaWorkflow: WorkflowTemplate = {
|
||||
id: "social-media",
|
||||
name: "社交媒体图文",
|
||||
description: "快速制作适合各社交平台的图文内容",
|
||||
icon: "share-2",
|
||||
category: "social",
|
||||
steps: socialMediaSteps,
|
||||
defaultContext: {
|
||||
industry: "social-media",
|
||||
style: "engaging",
|
||||
colorTone: "platform-optimized",
|
||||
},
|
||||
suggestedDimensions: [
|
||||
{ width: 1080, height: 1440, name: "小红书 3:4" },
|
||||
{ width: 1080, height: 1080, name: "微信公众号方图" },
|
||||
{ width: 900, height: 500, name: "微信公众号封面" },
|
||||
{ width: 1080, height: 1920, name: "抖音/微博故事" },
|
||||
],
|
||||
tags: ["社交媒体", "小红书", "公众号", "种草"],
|
||||
};
|
||||
|
||||
export default socialMediaWorkflow;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file 工作流类型测试
|
||||
* @description 测试工作流系统类型定义的正确性
|
||||
* @module components/content-creator/workflows/poster/types.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import type {
|
||||
WorkflowStepStatus,
|
||||
WorkflowStep,
|
||||
WorkflowTemplate,
|
||||
WorkflowCategory,
|
||||
InputFieldType,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* WorkflowStepStatus 生成器
|
||||
*/
|
||||
const workflowStepStatusArb = fc.constantFrom<WorkflowStepStatus>(
|
||||
"pending",
|
||||
"active",
|
||||
"completed",
|
||||
"skipped",
|
||||
"error",
|
||||
);
|
||||
|
||||
/**
|
||||
* WorkflowCategory 生成器
|
||||
*/
|
||||
const workflowCategoryArb = fc.constantFrom<WorkflowCategory>(
|
||||
"ecommerce",
|
||||
"branding",
|
||||
"social",
|
||||
"event",
|
||||
"education",
|
||||
"custom",
|
||||
);
|
||||
|
||||
/**
|
||||
* InputFieldType 生成器
|
||||
*/
|
||||
const inputFieldTypeArb = fc.constantFrom<InputFieldType>(
|
||||
"text",
|
||||
"textarea",
|
||||
"select",
|
||||
"multiselect",
|
||||
"color",
|
||||
"image",
|
||||
"number",
|
||||
);
|
||||
|
||||
describe("工作流类型属性测试", () => {
|
||||
/**
|
||||
* Property: WorkflowStepStatus 应该是有效的状态值
|
||||
*/
|
||||
test.prop([workflowStepStatusArb])(
|
||||
"WorkflowStepStatus 应该是有效的状态值",
|
||||
(status) => {
|
||||
const validStatuses = [
|
||||
"pending",
|
||||
"active",
|
||||
"completed",
|
||||
"skipped",
|
||||
"error",
|
||||
];
|
||||
expect(validStatuses).toContain(status);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: WorkflowCategory 应该是有效的分类值
|
||||
*/
|
||||
test.prop([workflowCategoryArb])(
|
||||
"WorkflowCategory 应该是有效的分类值",
|
||||
(category) => {
|
||||
const validCategories = [
|
||||
"ecommerce",
|
||||
"branding",
|
||||
"social",
|
||||
"event",
|
||||
"education",
|
||||
"custom",
|
||||
];
|
||||
expect(validCategories).toContain(category);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Property: InputFieldType 应该是有效的字段类型
|
||||
*/
|
||||
test.prop([inputFieldTypeArb])(
|
||||
"InputFieldType 应该是有效的字段类型",
|
||||
(fieldType) => {
|
||||
const validTypes = [
|
||||
"text",
|
||||
"textarea",
|
||||
"select",
|
||||
"multiselect",
|
||||
"color",
|
||||
"image",
|
||||
"number",
|
||||
];
|
||||
expect(validTypes).toContain(fieldType);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("工作流类型单元测试", () => {
|
||||
it("WorkflowStep 应该包含必要字段", () => {
|
||||
const step: WorkflowStep = {
|
||||
id: "test-step",
|
||||
name: "测试步骤",
|
||||
description: "这是一个测试步骤",
|
||||
agentId: "requirement",
|
||||
optional: false,
|
||||
};
|
||||
|
||||
expect(step.id).toBe("test-step");
|
||||
expect(step.name).toBe("测试步骤");
|
||||
expect(step.agentId).toBe("requirement");
|
||||
expect(step.optional).toBe(false);
|
||||
});
|
||||
|
||||
it("WorkflowStep 应该支持可选字段", () => {
|
||||
const step: WorkflowStep = {
|
||||
id: "test-step",
|
||||
name: "测试步骤",
|
||||
description: "描述",
|
||||
agentId: "style",
|
||||
optional: true,
|
||||
estimatedDuration: 30,
|
||||
dependencies: ["prev-step"],
|
||||
inputFields: [
|
||||
{
|
||||
key: "name",
|
||||
label: "名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(step.estimatedDuration).toBe(30);
|
||||
expect(step.dependencies).toContain("prev-step");
|
||||
expect(step.inputFields).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("WorkflowTemplate 应该包含必要字段", () => {
|
||||
const template: WorkflowTemplate = {
|
||||
id: "test-workflow",
|
||||
name: "测试工作流",
|
||||
description: "这是一个测试工作流",
|
||||
category: "custom",
|
||||
steps: [],
|
||||
};
|
||||
|
||||
expect(template.id).toBe("test-workflow");
|
||||
expect(template.name).toBe("测试工作流");
|
||||
expect(template.category).toBe("custom");
|
||||
expect(template.steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("WorkflowTemplate 应该支持可选字段", () => {
|
||||
const template: WorkflowTemplate = {
|
||||
id: "test-workflow",
|
||||
name: "测试工作流",
|
||||
description: "描述",
|
||||
icon: "star",
|
||||
category: "ecommerce",
|
||||
steps: [],
|
||||
defaultConfig: {
|
||||
autoExecute: true,
|
||||
skipOptionalSteps: false,
|
||||
},
|
||||
suggestedDimensions: [{ width: 1080, height: 1440, name: "3:4" }],
|
||||
tags: ["测试", "示例"],
|
||||
};
|
||||
|
||||
expect(template.icon).toBe("star");
|
||||
expect(template.defaultConfig?.autoExecute).toBe(true);
|
||||
expect(template.suggestedDimensions).toHaveLength(1);
|
||||
expect(template.tags).toContain("测试");
|
||||
});
|
||||
|
||||
describe("状态转换", () => {
|
||||
it("pending 应该可以转换为 active", () => {
|
||||
const validTransitions: Record<WorkflowStepStatus, WorkflowStepStatus[]> =
|
||||
{
|
||||
pending: ["active"],
|
||||
active: ["completed", "skipped", "error"],
|
||||
completed: [],
|
||||
skipped: [],
|
||||
error: ["active"], // 可以重试
|
||||
};
|
||||
|
||||
expect(validTransitions.pending).toContain("active");
|
||||
});
|
||||
|
||||
it("active 应该可以转换为 completed, skipped 或 error", () => {
|
||||
const fromActive: WorkflowStepStatus[] = [
|
||||
"completed",
|
||||
"skipped",
|
||||
"error",
|
||||
];
|
||||
fromActive.forEach((status) => {
|
||||
expect(["completed", "skipped", "error"]).toContain(status);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 海报工作流类型定义
|
||||
* @module components/content-creator/workflows/poster/types
|
||||
*/
|
||||
|
||||
import type { PosterAgentId } from "../../agents/base/types";
|
||||
|
||||
/**
|
||||
* 工作流步骤状态
|
||||
*/
|
||||
export type WorkflowStepStatus =
|
||||
| "pending"
|
||||
| "active"
|
||||
| "completed"
|
||||
| "skipped"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* 输入字段类型
|
||||
*/
|
||||
export type InputFieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "color"
|
||||
| "image"
|
||||
| "number";
|
||||
|
||||
/**
|
||||
* 输入字段定义
|
||||
*/
|
||||
export interface InputField {
|
||||
/** 字段键名 */
|
||||
key: string;
|
||||
/** 字段标签 */
|
||||
label: string;
|
||||
/** 字段类型 */
|
||||
type: InputFieldType;
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 选项列表(用于 select/multiselect) */
|
||||
options?: string[];
|
||||
/** 是否必填 */
|
||||
required: boolean;
|
||||
/** 默认值 */
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流步骤定义
|
||||
*/
|
||||
export interface WorkflowStep {
|
||||
/** 步骤 ID */
|
||||
id: string;
|
||||
/** 步骤名称 */
|
||||
name: string;
|
||||
/** 步骤描述 */
|
||||
description: string;
|
||||
/** 关联的 Agent ID */
|
||||
agentId: PosterAgentId;
|
||||
/** 步骤状态 */
|
||||
status?: WorkflowStepStatus;
|
||||
/** 是否可选 */
|
||||
optional: boolean;
|
||||
/** 预估时间(秒) */
|
||||
estimatedDuration?: number;
|
||||
/** 前置步骤 ID 列表 */
|
||||
dependencies?: string[];
|
||||
/** 步骤提示语 */
|
||||
prompts?: WorkflowPrompt[];
|
||||
/** 输入字段 */
|
||||
inputFields?: InputField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流提示语
|
||||
*/
|
||||
export interface WorkflowPrompt {
|
||||
/** 提示语 ID */
|
||||
id: string;
|
||||
/** 提示语文本 */
|
||||
text: string;
|
||||
/** 提示语类型 */
|
||||
type: "question" | "suggestion" | "example";
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流步骤状态
|
||||
*/
|
||||
export interface WorkflowStepState {
|
||||
/** 步骤 ID */
|
||||
stepId: string;
|
||||
/** 状态 */
|
||||
status: WorkflowStepStatus;
|
||||
/** 开始时间 */
|
||||
startedAt?: Date;
|
||||
/** 完成时间 */
|
||||
completedAt?: Date;
|
||||
/** 步骤结果 */
|
||||
result?: unknown;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建议尺寸
|
||||
*/
|
||||
export interface SuggestedDimension {
|
||||
/** 宽度 */
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
/** 名称 */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流模板
|
||||
*/
|
||||
export interface WorkflowTemplate {
|
||||
/** 工作流 ID */
|
||||
id: string;
|
||||
/** 工作流名称 */
|
||||
name: string;
|
||||
/** 工作流描述 */
|
||||
description: string;
|
||||
/** 工作流图标 */
|
||||
icon?: string;
|
||||
/** 工作流分类 */
|
||||
category: WorkflowCategory;
|
||||
/** 工作流步骤 */
|
||||
steps: WorkflowStep[];
|
||||
/** 默认配置 */
|
||||
defaultConfig?: WorkflowConfig;
|
||||
/** 默认上下文 */
|
||||
defaultContext?: Record<string, unknown>;
|
||||
/** 推荐场景 */
|
||||
recommendedScenes?: string[];
|
||||
/** 示例预览图 */
|
||||
previewImages?: string[];
|
||||
/** 建议尺寸 */
|
||||
suggestedDimensions?: SuggestedDimension[];
|
||||
/** 标签 */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流分类
|
||||
*/
|
||||
export type WorkflowCategory =
|
||||
| "ecommerce"
|
||||
| "branding"
|
||||
| "social"
|
||||
| "event"
|
||||
| "education"
|
||||
| "custom";
|
||||
|
||||
/**
|
||||
* 工作流配置
|
||||
*/
|
||||
export interface WorkflowConfig {
|
||||
/** 自动执行模式 */
|
||||
autoExecute?: boolean;
|
||||
/** 跳过可选步骤 */
|
||||
skipOptionalSteps?: boolean;
|
||||
/** 默认样式偏好 */
|
||||
stylePreference?: string;
|
||||
/** 默认输出平台 */
|
||||
targetPlatforms?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流实例状态
|
||||
*/
|
||||
export interface WorkflowState {
|
||||
/** 工作流模板 ID */
|
||||
templateId: string;
|
||||
/** 当前步骤索引 */
|
||||
currentStepIndex: number;
|
||||
/** 各步骤状态 */
|
||||
stepStates: Map<string, WorkflowStepState>;
|
||||
/** 工作流配置 */
|
||||
config: WorkflowConfig;
|
||||
/** 开始时间 */
|
||||
startedAt?: Date;
|
||||
/** 完成时间 */
|
||||
completedAt?: Date;
|
||||
/** 是否暂停 */
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流事件
|
||||
*/
|
||||
export type WorkflowEvent =
|
||||
| { type: "START"; templateId: string; config?: WorkflowConfig }
|
||||
| { type: "STEP_START"; stepId: string }
|
||||
| { type: "STEP_COMPLETE"; stepId: string; result: unknown }
|
||||
| { type: "STEP_SKIP"; stepId: string }
|
||||
| { type: "STEP_ERROR"; stepId: string; error: string }
|
||||
| { type: "PAUSE" }
|
||||
| { type: "RESUME" }
|
||||
| { type: "RESET" }
|
||||
| { type: "COMPLETE" };
|
||||
|
||||
/**
|
||||
* 工作流回调函数
|
||||
*/
|
||||
export interface WorkflowCallbacks {
|
||||
/** 步骤开始回调 */
|
||||
onStepStart?: (step: WorkflowStep) => void;
|
||||
/** 步骤完成回调 */
|
||||
onStepComplete?: (step: WorkflowStep, result: unknown) => void;
|
||||
/** 步骤跳过回调 */
|
||||
onStepSkip?: (step: WorkflowStep) => void;
|
||||
/** 步骤错误回调 */
|
||||
onStepError?: (step: WorkflowStep, error: string) => void;
|
||||
/** 工作流完成回调 */
|
||||
onWorkflowComplete?: (results: Map<string, unknown>) => void;
|
||||
/** 进度更新回调 */
|
||||
onProgressUpdate?: (progress: number, currentStep: WorkflowStep) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流上下文
|
||||
*/
|
||||
export interface WorkflowContext {
|
||||
/** 项目 ID */
|
||||
projectId?: string;
|
||||
/** 品牌人设 ID */
|
||||
brandPersonaId?: string;
|
||||
/** 用户输入 */
|
||||
userInput: string;
|
||||
/** 画布 JSON */
|
||||
canvasJson?: Record<string, unknown>;
|
||||
/** 选中的素材 */
|
||||
selectedMaterials?: string[];
|
||||
/** 目标平台 */
|
||||
targetPlatforms?: string[];
|
||||
/** 额外上下文 */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* @file BrandPersonaDialog.tsx
|
||||
* @description 品牌人设编辑对话框组件,支持分步骤创建品牌人设
|
||||
* @module components/projects/dialogs/BrandPersonaDialog
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
SaveIcon,
|
||||
Loader2Icon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrandPersona,
|
||||
BrandTone,
|
||||
DesignConfig,
|
||||
ColorScheme,
|
||||
Typography,
|
||||
BrandPersonality,
|
||||
DesignStyle,
|
||||
CreateBrandExtensionRequest,
|
||||
BrandPersonaTemplate,
|
||||
} from "@/types/brand-persona";
|
||||
import {
|
||||
BRAND_PERSONALITY_NAMES,
|
||||
BRAND_PERSONALITY_DESCRIPTIONS,
|
||||
DESIGN_STYLE_NAMES,
|
||||
PRESET_COLOR_SCHEMES,
|
||||
AVAILABLE_FONTS,
|
||||
DEFAULT_DESIGN_CONFIG,
|
||||
} from "@/types/brand-persona";
|
||||
|
||||
export interface BrandPersonaDialogProps {
|
||||
/** 是否打开 */
|
||||
open: boolean;
|
||||
/** 关闭回调 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 人设 ID */
|
||||
personaId: string;
|
||||
/** 人设名称 */
|
||||
personaName: string;
|
||||
/** 现有品牌人设(编辑时传入) */
|
||||
brandPersona?: BrandPersona | null;
|
||||
/** 模板列表 */
|
||||
templates?: BrandPersonaTemplate[];
|
||||
/** 保存回调 */
|
||||
onSave: (req: CreateBrandExtensionRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 步骤定义 */
|
||||
const STEPS = [
|
||||
{ id: 1, name: "品牌调性", key: "brandTone" },
|
||||
{ id: 2, name: "配色方案", key: "colorScheme" },
|
||||
{ id: 3, name: "字体设置", key: "typography" },
|
||||
{ id: 4, name: "预览确认", key: "preview" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 品牌人设编辑对话框
|
||||
*/
|
||||
export function BrandPersonaDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
personaId,
|
||||
personaName,
|
||||
brandPersona,
|
||||
templates = [],
|
||||
onSave,
|
||||
}: BrandPersonaDialogProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// 品牌调性状态
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [personality, setPersonality] =
|
||||
useState<BrandPersonality>("professional");
|
||||
const [voiceTone, setVoiceTone] = useState("");
|
||||
const [targetAudience, setTargetAudience] = useState("");
|
||||
|
||||
// 设计配置状态
|
||||
const [primaryStyle, setPrimaryStyle] = useState<DesignStyle>("modern");
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(
|
||||
DEFAULT_DESIGN_CONFIG.colorScheme,
|
||||
);
|
||||
|
||||
// 字体配置状态
|
||||
const [typography, setTypography] = useState<Typography>(
|
||||
DEFAULT_DESIGN_CONFIG.typography,
|
||||
);
|
||||
|
||||
const isEditing = !!brandPersona?.brandTone;
|
||||
|
||||
// 同步编辑数据
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (brandPersona?.brandTone) {
|
||||
// 编辑模式:加载现有数据
|
||||
setKeywords(brandPersona.brandTone.keywords || []);
|
||||
setPersonality(brandPersona.brandTone.personality || "professional");
|
||||
setVoiceTone(brandPersona.brandTone.voiceTone || "");
|
||||
setTargetAudience(brandPersona.brandTone.targetAudience || "");
|
||||
|
||||
if (brandPersona.design) {
|
||||
setPrimaryStyle(brandPersona.design.primaryStyle || "modern");
|
||||
setColorScheme(
|
||||
brandPersona.design.colorScheme ||
|
||||
DEFAULT_DESIGN_CONFIG.colorScheme,
|
||||
);
|
||||
setTypography(
|
||||
brandPersona.design.typography || DEFAULT_DESIGN_CONFIG.typography,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 新建模式:重置表单
|
||||
setKeywords([]);
|
||||
setKeywordInput("");
|
||||
setPersonality("professional");
|
||||
setVoiceTone("");
|
||||
setTargetAudience("");
|
||||
setPrimaryStyle("modern");
|
||||
setColorScheme(DEFAULT_DESIGN_CONFIG.colorScheme);
|
||||
setTypography(DEFAULT_DESIGN_CONFIG.typography);
|
||||
}
|
||||
setStep(1);
|
||||
}
|
||||
}, [brandPersona, open]);
|
||||
|
||||
// 添加关键词
|
||||
const handleAddKeyword = useCallback(() => {
|
||||
const trimmed = keywordInput.trim();
|
||||
if (trimmed && !keywords.includes(trimmed)) {
|
||||
setKeywords([...keywords, trimmed]);
|
||||
setKeywordInput("");
|
||||
}
|
||||
}, [keywordInput, keywords]);
|
||||
|
||||
// 删除关键词
|
||||
const handleRemoveKeyword = useCallback(
|
||||
(keyword: string) => {
|
||||
setKeywords(keywords.filter((k) => k !== keyword));
|
||||
},
|
||||
[keywords],
|
||||
);
|
||||
|
||||
// 应用模板
|
||||
const handleApplyTemplate = useCallback((template: BrandPersonaTemplate) => {
|
||||
setKeywords(template.brandTone.keywords || []);
|
||||
setPersonality(template.brandTone.personality || "professional");
|
||||
setVoiceTone(template.brandTone.voiceTone || "");
|
||||
setTargetAudience(template.brandTone.targetAudience || "");
|
||||
setPrimaryStyle(template.design.primaryStyle || "modern");
|
||||
setColorScheme(
|
||||
template.design.colorScheme || DEFAULT_DESIGN_CONFIG.colorScheme,
|
||||
);
|
||||
setTypography(
|
||||
template.design.typography || DEFAULT_DESIGN_CONFIG.typography,
|
||||
);
|
||||
toast.success(`已应用模板: ${template.name}`);
|
||||
}, []);
|
||||
|
||||
// 应用预设配色
|
||||
const handleApplyPresetColor = useCallback(
|
||||
(preset: (typeof PRESET_COLOR_SCHEMES)[0]) => {
|
||||
setColorScheme(preset.colors);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const brandTone: BrandTone = {
|
||||
keywords,
|
||||
personality,
|
||||
voiceTone: voiceTone || undefined,
|
||||
targetAudience: targetAudience || undefined,
|
||||
};
|
||||
|
||||
const design: DesignConfig = {
|
||||
primaryStyle,
|
||||
colorScheme,
|
||||
typography,
|
||||
};
|
||||
|
||||
await onSave({
|
||||
personaId,
|
||||
brandTone,
|
||||
design,
|
||||
});
|
||||
|
||||
toast.success(isEditing ? "品牌人设已更新" : "品牌人设已创建");
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(String(error) || "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染步骤 1:品牌调性
|
||||
const renderBrandToneStep = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 模板选择 */}
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>快速应用模板</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyTemplate(template)}
|
||||
>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 品牌关键词 */}
|
||||
<div className="space-y-2">
|
||||
<Label>品牌关键词</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
placeholder="输入关键词后按回车"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddKeyword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddKeyword}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{keywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{keywords.map((keyword) => (
|
||||
<span
|
||||
key={keyword}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded text-sm"
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 品牌个性 */}
|
||||
<div className="space-y-2">
|
||||
<Label>品牌个性</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(BRAND_PERSONALITY_NAMES) as BrandPersonality[]).map(
|
||||
(p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPersonality(p)}
|
||||
className={cn(
|
||||
"p-3 border rounded-lg text-left transition-colors",
|
||||
personality === p
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/10",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm">
|
||||
{BRAND_PERSONALITY_NAMES[p]}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{BRAND_PERSONALITY_DESCRIPTIONS[p]}
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 品牌语调 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voice-tone">品牌语调</Label>
|
||||
<Input
|
||||
id="voice-tone"
|
||||
value={voiceTone}
|
||||
onChange={(e) => setVoiceTone(e.target.value)}
|
||||
placeholder="例如:专业但不刻板,友好但不随意"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 目标受众 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-audience">目标受众</Label>
|
||||
<Textarea
|
||||
id="target-audience"
|
||||
value={targetAudience}
|
||||
onChange={(e) => setTargetAudience(e.target.value)}
|
||||
placeholder="描述你的目标用户,如:25-35岁的都市女性,追求品质生活..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染步骤 2:配色方案
|
||||
const renderColorSchemeStep = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 设计风格 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设计风格</Label>
|
||||
<Select
|
||||
value={primaryStyle}
|
||||
onValueChange={(v) => setPrimaryStyle(v as DesignStyle)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(DESIGN_STYLE_NAMES) as DesignStyle[]).map((style) => (
|
||||
<SelectItem key={style} value={style}>
|
||||
{DESIGN_STYLE_NAMES[style]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 预设配色 */}
|
||||
<div className="space-y-2">
|
||||
<Label>预设配色方案</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PRESET_COLOR_SCHEMES.map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
type="button"
|
||||
onClick={() => handleApplyPresetColor(preset)}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="flex rounded-lg overflow-hidden mb-1 border">
|
||||
<div
|
||||
className="w-6 h-6"
|
||||
style={{ backgroundColor: preset.colors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6"
|
||||
style={{ backgroundColor: preset.colors.secondary }}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6"
|
||||
style={{ backgroundColor: preset.colors.accent }}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6"
|
||||
style={{ backgroundColor: preset.colors.background }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs">{preset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义配色 */}
|
||||
<div className="space-y-2">
|
||||
<Label>自定义配色</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ key: "primary" as const, label: "主色" },
|
||||
{ key: "secondary" as const, label: "辅色" },
|
||||
{ key: "accent" as const, label: "强调色" },
|
||||
{ key: "background" as const, label: "背景色" },
|
||||
{ key: "text" as const, label: "主文字" },
|
||||
{ key: "textSecondary" as const, label: "次要文字" },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={colorScheme[key]}
|
||||
onChange={(e) =>
|
||||
setColorScheme({ ...colorScheme, [key]: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 rounded cursor-pointer border"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-xs font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{colorScheme[key]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配色预览 */}
|
||||
<div className="space-y-2">
|
||||
<Label>配色预览</Label>
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ backgroundColor: colorScheme.background }}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
color: colorScheme.primary,
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
标题示例
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
color: colorScheme.text,
|
||||
marginTop: "4px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
这是正文内容示例,展示主要文字颜色。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: colorScheme.textSecondary,
|
||||
marginTop: "2px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
这是次要文字内容示例。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
backgroundColor: colorScheme.accent,
|
||||
color: "#FFFFFF",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "4px",
|
||||
marginTop: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
行动按钮
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染步骤 3:字体设置
|
||||
const renderTypographyStep = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 标题字体 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>标题字体</Label>
|
||||
<Select
|
||||
value={typography.titleFont}
|
||||
onValueChange={(v) =>
|
||||
setTypography({ ...typography, titleFont: v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_FONTS.map((font) => (
|
||||
<SelectItem key={font.id} value={font.name}>
|
||||
{font.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>正文字体</Label>
|
||||
<Select
|
||||
value={typography.bodyFont}
|
||||
onValueChange={(v) => setTypography({ ...typography, bodyFont: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_FONTS.map((font) => (
|
||||
<SelectItem key={font.id} value={font.name}>
|
||||
{font.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字号设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>标题字号: {typography.titleSize}px</Label>
|
||||
<Slider
|
||||
value={[typography.titleSize]}
|
||||
onValueChange={([v]) =>
|
||||
setTypography({ ...typography, titleSize: v })
|
||||
}
|
||||
min={48}
|
||||
max={120}
|
||||
step={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>正文字号: {typography.bodySize}px</Label>
|
||||
<Slider
|
||||
value={[typography.bodySize]}
|
||||
onValueChange={([v]) =>
|
||||
setTypography({ ...typography, bodySize: v })
|
||||
}
|
||||
min={14}
|
||||
max={36}
|
||||
step={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字重设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>标题字重</Label>
|
||||
<Select
|
||||
value={String(typography.titleWeight)}
|
||||
onValueChange={(v) =>
|
||||
setTypography({ ...typography, titleWeight: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">Regular (400)</SelectItem>
|
||||
<SelectItem value="500">Medium (500)</SelectItem>
|
||||
<SelectItem value="600">SemiBold (600)</SelectItem>
|
||||
<SelectItem value="700">Bold (700)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>正文字重</Label>
|
||||
<Select
|
||||
value={String(typography.bodyWeight)}
|
||||
onValueChange={(v) =>
|
||||
setTypography({ ...typography, bodyWeight: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="300">Light (300)</SelectItem>
|
||||
<SelectItem value="400">Regular (400)</SelectItem>
|
||||
<SelectItem value="500">Medium (500)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字体预览 */}
|
||||
<div className="space-y-2">
|
||||
<Label>字体预览</Label>
|
||||
<div className="p-4 border rounded-lg bg-white">
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: typography.titleFont,
|
||||
fontWeight: typography.titleWeight,
|
||||
fontSize: `${Math.min(typography.titleSize, 48)}px`,
|
||||
lineHeight: typography.lineHeight,
|
||||
}}
|
||||
>
|
||||
标题文字示例
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: typography.bodyFont,
|
||||
fontWeight: typography.bodyWeight,
|
||||
fontSize: `${typography.bodySize}px`,
|
||||
lineHeight: typography.lineHeight,
|
||||
marginTop: "8px",
|
||||
}}
|
||||
>
|
||||
这是正文内容示例,用于展示正文字体效果。
|
||||
好的字体搭配能够提升设计的专业感和可读性。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染步骤 4:预览确认
|
||||
const renderPreviewStep = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
请确认以下品牌人设配置:
|
||||
</div>
|
||||
|
||||
{/* 品牌调性预览 */}
|
||||
<div className="p-3 border rounded-lg space-y-2">
|
||||
<div className="font-medium">品牌调性</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键词:</span>
|
||||
{keywords.length > 0 ? keywords.join("、") : "未设置"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">品牌个性:</span>
|
||||
{BRAND_PERSONALITY_NAMES[personality]}
|
||||
</div>
|
||||
{voiceTone && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">品牌语调:</span>
|
||||
{voiceTone}
|
||||
</div>
|
||||
)}
|
||||
{targetAudience && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">目标受众:</span>
|
||||
{targetAudience}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设计配置预览 */}
|
||||
<div className="p-3 border rounded-lg space-y-2">
|
||||
<div className="font-medium">设计配置</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">设计风格:</span>
|
||||
{DESIGN_STYLE_NAMES[primaryStyle]}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">配色方案:</span>
|
||||
<div className="flex rounded overflow-hidden border">
|
||||
<div
|
||||
className="w-4 h-4"
|
||||
style={{ backgroundColor: colorScheme.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-4 h-4"
|
||||
style={{ backgroundColor: colorScheme.secondary }}
|
||||
/>
|
||||
<div
|
||||
className="w-4 h-4"
|
||||
style={{ backgroundColor: colorScheme.accent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">标题字体:</span>
|
||||
{typography.titleFont} ({typography.titleSize}px)
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">正文字体:</span>
|
||||
{typography.bodyFont} ({typography.bodySize}px)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 综合预览 */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">综合预览</div>
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ backgroundColor: colorScheme.background }}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: typography.titleFont,
|
||||
fontWeight: typography.titleWeight,
|
||||
fontSize: `${Math.min(typography.titleSize, 36)}px`,
|
||||
color: colorScheme.primary,
|
||||
lineHeight: typography.lineHeight,
|
||||
}}
|
||||
>
|
||||
{personaName}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: typography.bodyFont,
|
||||
fontWeight: typography.bodyWeight,
|
||||
fontSize: `${typography.bodySize}px`,
|
||||
color: colorScheme.text,
|
||||
lineHeight: typography.lineHeight,
|
||||
marginTop: "8px",
|
||||
}}
|
||||
>
|
||||
{keywords.length > 0
|
||||
? `品牌关键词:${keywords.join("、")}`
|
||||
: "这是一段示例正文内容,展示品牌人设的整体视觉效果。"}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: typography.bodyFont,
|
||||
fontSize: `${typography.bodySize - 2}px`,
|
||||
color: colorScheme.textSecondary,
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{BRAND_PERSONALITY_NAMES[personality]} ·{" "}
|
||||
{DESIGN_STYLE_NAMES[primaryStyle]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return renderBrandToneStep();
|
||||
case 2:
|
||||
return renderColorSchemeStep();
|
||||
case 3:
|
||||
return renderTypographyStep();
|
||||
case 4:
|
||||
return renderPreviewStep();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? "编辑品牌人设" : "创建品牌人设"} - {personaName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<div className="flex border-b pb-3">
|
||||
{STEPS.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex-1 text-center text-sm",
|
||||
step === s.id
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-6 h-6 rounded-full text-xs leading-6 mr-1",
|
||||
step > s.id
|
||||
? "bg-green-500 text-white"
|
||||
: step === s.id
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{step > s.id ? "✓" : s.id}
|
||||
</span>
|
||||
{s.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<div className="py-4 min-h-[300px]">{renderStepContent()}</div>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
<ChevronLeftIcon className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{step < STEPS.length ? (
|
||||
<Button onClick={() => setStep((s) => s + 1)}>
|
||||
下一步
|
||||
<ChevronRightIcon className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2Icon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saving ? "保存中..." : "完成创建"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrandPersonaDialog;
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file MaterialPreviewDialog.tsx
|
||||
* @description 素材预览对话框组件
|
||||
* @module components/projects/dialogs/MaterialPreviewDialog
|
||||
* @requirements 7.5
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LinkIcon,
|
||||
ExternalLinkIcon,
|
||||
PaletteIcon,
|
||||
LayoutIcon,
|
||||
} from "lucide-react";
|
||||
import type { Material, MaterialType } from "@/types/material";
|
||||
import { MaterialTypeLabels } from "@/types/material";
|
||||
|
||||
export interface MaterialPreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
material: Material | null;
|
||||
}
|
||||
|
||||
const MaterialTypeIcons: Record<MaterialType, typeof FileIcon> = {
|
||||
document: FileIcon,
|
||||
image: ImageIcon,
|
||||
text: FileTextIcon,
|
||||
data: DatabaseIcon,
|
||||
link: LinkIcon,
|
||||
icon: FileIcon,
|
||||
color: PaletteIcon,
|
||||
layout: LayoutIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材预览对话框
|
||||
*
|
||||
* 支持预览图片、文本、链接等类型的素材。
|
||||
*/
|
||||
export function MaterialPreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
material,
|
||||
}: MaterialPreviewDialogProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [textContent, setTextContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!material || !open) {
|
||||
setImageSrc(null);
|
||||
setTextContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理图片类型
|
||||
if (material.type === "image" && material.filePath) {
|
||||
const src = convertFileSrc(material.filePath);
|
||||
setImageSrc(src);
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (material.content) {
|
||||
setTextContent(material.content);
|
||||
}
|
||||
}, [material, open]);
|
||||
|
||||
if (!material) return null;
|
||||
|
||||
const Icon = MaterialTypeIcons[material.type as MaterialType] || FileIcon;
|
||||
|
||||
const renderPreview = () => {
|
||||
switch (material.type as MaterialType) {
|
||||
case "image":
|
||||
return imageSrc ? (
|
||||
<div className="flex items-center justify-center p-4 bg-muted/30 rounded-lg">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={material.name}
|
||||
className="max-w-full max-h-[400px] object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 bg-muted/30 rounded-lg">
|
||||
<p className="text-muted-foreground">无法加载图片</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "text":
|
||||
return textContent ? (
|
||||
<ScrollArea className="h-[300px] rounded-lg border p-4 bg-muted/30">
|
||||
<pre className="whitespace-pre-wrap text-sm">{textContent}</pre>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 bg-muted/30 rounded-lg">
|
||||
<p className="text-muted-foreground">无文本内容</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<div className="p-4 bg-muted/30 rounded-lg">
|
||||
<a
|
||||
href={material.content || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
{material.content || "无链接"}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "document":
|
||||
case "data":
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 bg-muted/30 rounded-lg">
|
||||
<Icon className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{material.filePath ? "文件预览暂不支持" : "无文件"}
|
||||
</p>
|
||||
{material.filePath && (
|
||||
<p className="text-xs text-muted-foreground mt-2 max-w-full truncate px-4">
|
||||
{material.filePath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
{material.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{MaterialTypeLabels[material.type as MaterialType] ||
|
||||
material.type}
|
||||
</Badge>
|
||||
{material.fileSize && (
|
||||
<Badge variant="secondary">
|
||||
{(material.fileSize / 1024).toFixed(1)} KB
|
||||
</Badge>
|
||||
)}
|
||||
{material.mimeType && (
|
||||
<Badge variant="secondary">{material.mimeType}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预览区域 */}
|
||||
{renderPreview()}
|
||||
|
||||
{/* 描述 */}
|
||||
{material.description && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">描述</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{material.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{material.tags.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">标签</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{material.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
创建于 {new Date(material.createdAt * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaterialPreviewDialog;
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file PersonaDialog.tsx
|
||||
* @description 人设编辑对话框组件
|
||||
* @description 人设编辑对话框组件,支持 AI 一键生成
|
||||
* @module components/projects/dialogs/PersonaDialog
|
||||
* @requirements 6.1, 6.2, 6.3, 6.4
|
||||
*/
|
||||
@@ -17,7 +17,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SaveIcon, Loader2Icon } from "lucide-react";
|
||||
import { SaveIcon, Loader2Icon, SparklesIcon } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import type { Persona, CreatePersonaRequest } from "@/types/persona";
|
||||
|
||||
export interface PersonaDialogProps {
|
||||
@@ -44,6 +46,8 @@ export function PersonaDialog({
|
||||
onSave,
|
||||
}: PersonaDialogProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [style, setStyle] = useState("");
|
||||
@@ -73,9 +77,42 @@ export function PersonaDialog({
|
||||
setTargetAudience("");
|
||||
setForbiddenWords("");
|
||||
setPreferredWords("");
|
||||
setAiPrompt("");
|
||||
}
|
||||
}, [persona, open]);
|
||||
|
||||
// AI 一键生成人设
|
||||
const handleAIGenerate = async () => {
|
||||
if (!aiPrompt.trim()) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await invoke<{
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
tone: string;
|
||||
targetAudience: string;
|
||||
forbiddenWords: string[];
|
||||
preferredWords: string[];
|
||||
}>("generate_persona", { prompt: aiPrompt.trim() });
|
||||
|
||||
// 填充表单
|
||||
setName(result.name || "");
|
||||
setDescription(result.description || "");
|
||||
setStyle(result.style || "");
|
||||
setTone(result.tone || "");
|
||||
setTargetAudience(result.targetAudience || "");
|
||||
setForbiddenWords(result.forbiddenWords?.join("、") || "");
|
||||
setPreferredWords(result.preferredWords?.join("、") || "");
|
||||
toast.success("人设生成成功");
|
||||
} catch (error) {
|
||||
console.error("AI 生成人设失败:", error);
|
||||
toast.error(String(error) || "AI 生成人设失败");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
@@ -114,6 +151,49 @@ export function PersonaDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* AI 一键生成 */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-2 p-3 bg-muted/50 rounded-lg border border-dashed">
|
||||
<Label htmlFor="ai-prompt" className="flex items-center gap-1.5">
|
||||
<SparklesIcon className="h-4 w-4 text-primary" />
|
||||
AI 一键生成
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="ai-prompt"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="描述你想要的人设,例如:一个幽默风趣的科技博主"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAIGenerate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAIGenerate}
|
||||
disabled={generating || !aiPrompt.trim()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5">
|
||||
{generating ? "生成中" : "生成"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入简单描述,AI 将自动生成完整人设配置
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-name">人设名称 *</Label>
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
*/
|
||||
|
||||
export { PersonaDialog } from "./PersonaDialog";
|
||||
export { BrandPersonaDialog } from "./BrandPersonaDialog";
|
||||
export { TemplateDialog } from "./TemplateDialog";
|
||||
export { MaterialUploadDialog } from "./MaterialUploadDialog";
|
||||
export { MaterialPreviewDialog } from "./MaterialPreviewDialog";
|
||||
|
||||
@@ -20,10 +20,16 @@ import {
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
PaletteIcon,
|
||||
LayoutIcon,
|
||||
} from "lucide-react";
|
||||
import type { MaterialType, UploadMaterialRequest } from "@/types/material";
|
||||
import type {
|
||||
Material,
|
||||
MaterialType,
|
||||
UploadMaterialRequest,
|
||||
} from "@/types/material";
|
||||
import { MaterialTypeLabels } from "@/types/material";
|
||||
import { MaterialUploadDialog } from "../dialogs";
|
||||
import { MaterialUploadDialog, MaterialPreviewDialog } from "../dialogs";
|
||||
|
||||
export interface MaterialTabProps {
|
||||
/** 项目 ID */
|
||||
@@ -36,6 +42,9 @@ const MaterialTypeIcons: Record<MaterialType, typeof FileIcon> = {
|
||||
text: FileTextIcon,
|
||||
data: DatabaseIcon,
|
||||
link: LinkIcon,
|
||||
icon: FileIcon,
|
||||
color: PaletteIcon,
|
||||
layout: LayoutIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,6 +64,7 @@ export function MaterialTab({ projectId }: MaterialTabProps) {
|
||||
} = useMaterials(projectId);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [previewMaterial, setPreviewMaterial] = useState<Material | null>(null);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
@@ -163,7 +173,12 @@ export function MaterialTab({ projectId }: MaterialTabProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setPreviewMaterial(material)}
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -217,6 +232,13 @@ export function MaterialTab({ projectId }: MaterialTabProps) {
|
||||
projectId={projectId}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
{/* 预览对话框 */}
|
||||
<MaterialPreviewDialog
|
||||
open={!!previewMaterial}
|
||||
onOpenChange={(open) => !open && setPreviewMaterial(null)}
|
||||
material={previewMaterial}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ export { useWorkspace } from "./useWorkspace";
|
||||
export { useProjects } from "./useProjects";
|
||||
export { useProject } from "./useProject";
|
||||
export { usePersonas } from "./usePersonas";
|
||||
export { useBrandPersona } from "./useBrandPersona";
|
||||
export { useMaterials } from "./useMaterials";
|
||||
export { usePosterMaterial } from "./usePosterMaterial";
|
||||
export { useTemplates } from "./useTemplates";
|
||||
export { useProjectContext } from "./useProjectContext";
|
||||
export type { UseSoundReturn } from "./useSound";
|
||||
@@ -21,7 +23,9 @@ export type {
|
||||
export type { UseProjectsReturn } from "./useProjects";
|
||||
export type { UseProjectReturn } from "./useProject";
|
||||
export type { UsePersonasReturn } from "./usePersonas";
|
||||
export type { UseBrandPersonaReturn } from "./useBrandPersona";
|
||||
export type { UseMaterialsReturn } from "./useMaterials";
|
||||
export type { UsePosterMaterialReturn } from "./usePosterMaterial";
|
||||
export type { UseTemplatesReturn } from "./useTemplates";
|
||||
export type { UseProjectContextReturn } from "./useProjectContext";
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file useBrandPersona.ts
|
||||
* @description 品牌人设管理 Hook,提供品牌人设扩展的 CRUD 操作
|
||||
* @module hooks/useBrandPersona
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
BrandPersona,
|
||||
BrandPersonaExtension,
|
||||
BrandPersonaTemplate,
|
||||
CreateBrandExtensionRequest,
|
||||
UpdateBrandExtensionRequest,
|
||||
} from "@/types/brand-persona";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseBrandPersonaReturn {
|
||||
/** 品牌人设(完整视图) */
|
||||
brandPersona: BrandPersona | null;
|
||||
/** 品牌人设扩展 */
|
||||
extension: BrandPersonaExtension | null;
|
||||
/** 品牌人设模板列表 */
|
||||
templates: BrandPersonaTemplate[];
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新数据 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 保存品牌扩展 */
|
||||
save: (req: CreateBrandExtensionRequest) => Promise<BrandPersonaExtension>;
|
||||
/** 更新品牌扩展 */
|
||||
update: (
|
||||
update: UpdateBrandExtensionRequest,
|
||||
) => Promise<BrandPersonaExtension>;
|
||||
/** 删除品牌扩展 */
|
||||
remove: () => Promise<void>;
|
||||
/** 加载模板列表 */
|
||||
loadTemplates: () => Promise<void>;
|
||||
/** 应用模板 */
|
||||
applyTemplate: (
|
||||
template: BrandPersonaTemplate,
|
||||
) => Promise<BrandPersonaExtension>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌人设管理 Hook
|
||||
*
|
||||
* @param personaId - 人设 ID
|
||||
*/
|
||||
export function useBrandPersona(
|
||||
personaId: string | null,
|
||||
): UseBrandPersonaReturn {
|
||||
const [brandPersona, setBrandPersona] = useState<BrandPersona | null>(null);
|
||||
const [extension, setExtension] = useState<BrandPersonaExtension | null>(
|
||||
null,
|
||||
);
|
||||
const [templates, setTemplates] = useState<BrandPersonaTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新品牌人设数据 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!personaId) {
|
||||
setBrandPersona(null);
|
||||
setExtension(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [brandPersonaData, extensionData] = await Promise.all([
|
||||
invoke<BrandPersona | null>("get_brand_persona", { personaId }),
|
||||
invoke<BrandPersonaExtension | null>("get_brand_extension", {
|
||||
personaId,
|
||||
}),
|
||||
]);
|
||||
|
||||
setBrandPersona(brandPersonaData);
|
||||
setExtension(extensionData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [personaId]);
|
||||
|
||||
/** 保存品牌扩展 */
|
||||
const save = useCallback(
|
||||
async (
|
||||
req: CreateBrandExtensionRequest,
|
||||
): Promise<BrandPersonaExtension> => {
|
||||
const result = await invoke<BrandPersonaExtension>(
|
||||
"save_brand_extension",
|
||||
{ req },
|
||||
);
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新品牌扩展 */
|
||||
const update = useCallback(
|
||||
async (
|
||||
updateData: UpdateBrandExtensionRequest,
|
||||
): Promise<BrandPersonaExtension> => {
|
||||
if (!personaId) {
|
||||
throw new Error("人设 ID 不能为空");
|
||||
}
|
||||
const result = await invoke<BrandPersonaExtension>(
|
||||
"update_brand_extension",
|
||||
{
|
||||
personaId,
|
||||
update: updateData,
|
||||
},
|
||||
);
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[personaId, refresh],
|
||||
);
|
||||
|
||||
/** 删除品牌扩展 */
|
||||
const remove = useCallback(async (): Promise<void> => {
|
||||
if (!personaId) {
|
||||
throw new Error("人设 ID 不能为空");
|
||||
}
|
||||
await invoke("delete_brand_extension", { personaId });
|
||||
await refresh();
|
||||
}, [personaId, refresh]);
|
||||
|
||||
/** 加载模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const list = await invoke<BrandPersonaTemplate[]>(
|
||||
"list_brand_persona_templates",
|
||||
);
|
||||
setTemplates(list);
|
||||
} catch (err) {
|
||||
console.error("加载品牌人设模板失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 应用模板 */
|
||||
const applyTemplate = useCallback(
|
||||
async (template: BrandPersonaTemplate): Promise<BrandPersonaExtension> => {
|
||||
if (!personaId) {
|
||||
throw new Error("人设 ID 不能为空");
|
||||
}
|
||||
|
||||
const req: CreateBrandExtensionRequest = {
|
||||
personaId,
|
||||
brandTone: template.brandTone,
|
||||
design: template.design,
|
||||
visual: template.visual,
|
||||
};
|
||||
|
||||
return save(req);
|
||||
},
|
||||
[personaId, save],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
brandPersona,
|
||||
extension,
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
save,
|
||||
update,
|
||||
remove,
|
||||
loadTemplates,
|
||||
applyTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
export default useBrandPersona;
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @file useMultiPlatformExport.ts
|
||||
* @description 多平台导出 Hook
|
||||
* @module hooks/useMultiPlatformExport
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { fabric } from "fabric";
|
||||
import {
|
||||
allPlatformSpecs,
|
||||
getPlatformSpec,
|
||||
checkFileCompliance,
|
||||
type PlatformId,
|
||||
type SizeSpec,
|
||||
type ExportConfig,
|
||||
type BatchExportConfig,
|
||||
type ExportResult,
|
||||
} from "@/components/content-creator/canvas/poster/platforms";
|
||||
import {
|
||||
calculateSmartCrop,
|
||||
previewCrop,
|
||||
} from "@/components/content-creator/canvas/poster/utils/smartCrop";
|
||||
|
||||
/**
|
||||
* 导出进度
|
||||
*/
|
||||
export interface ExportProgress {
|
||||
/** 当前索引 */
|
||||
current: number;
|
||||
/** 总数 */
|
||||
total: number;
|
||||
/** 当前平台 */
|
||||
currentPlatform?: string;
|
||||
/** 当前尺寸 */
|
||||
currentSize?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 返回值
|
||||
*/
|
||||
export interface UseMultiPlatformExportReturn {
|
||||
/** 所有平台规范 */
|
||||
platforms: typeof allPlatformSpecs;
|
||||
/** 导出单个尺寸 */
|
||||
exportSingle: (
|
||||
canvas: fabric.Canvas,
|
||||
config: ExportConfig,
|
||||
) => Promise<ExportResult>;
|
||||
/** 批量导出 */
|
||||
exportBatch: (
|
||||
canvas: fabric.Canvas,
|
||||
config: BatchExportConfig,
|
||||
) => Promise<ExportResult[]>;
|
||||
/** 预览导出效果 */
|
||||
previewExport: (
|
||||
canvas: fabric.Canvas,
|
||||
platformId: PlatformId,
|
||||
sizeSpec: SizeSpec,
|
||||
) => Promise<string | null>;
|
||||
/** 检查文件合规性 */
|
||||
checkCompliance: typeof checkFileCompliance;
|
||||
/** 获取平台规范 */
|
||||
getPlatform: typeof getPlatformSpec;
|
||||
/** 导出进度 */
|
||||
progress: ExportProgress | null;
|
||||
/** 是否正在导出 */
|
||||
isExporting: boolean;
|
||||
/** 导出结果 */
|
||||
results: ExportResult[];
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多平台导出 Hook
|
||||
*/
|
||||
export function useMultiPlatformExport(): UseMultiPlatformExportReturn {
|
||||
const [progress, setProgress] = useState<ExportProgress | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [results, setResults] = useState<ExportResult[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 导出单个尺寸
|
||||
*/
|
||||
const exportSingle = useCallback(
|
||||
async (
|
||||
canvas: fabric.Canvas,
|
||||
config: ExportConfig,
|
||||
): Promise<ExportResult> => {
|
||||
const { platform, sizeSpec, format, quality } = config;
|
||||
|
||||
try {
|
||||
// 获取原始画布尺寸
|
||||
const sourceWidth = canvas.getWidth();
|
||||
const sourceHeight = canvas.getHeight();
|
||||
|
||||
// 计算裁切区域
|
||||
const cropResult = calculateSmartCrop({
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec: sizeSpec,
|
||||
strategy: "smart",
|
||||
});
|
||||
|
||||
// 创建临时画布
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = sizeSpec.width;
|
||||
tempCanvas.height = sizeSpec.height;
|
||||
const ctx = tempCanvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("无法创建 canvas context");
|
||||
}
|
||||
|
||||
// 获取原始画布图像
|
||||
const sourceDataUrl = canvas.toDataURL({
|
||||
format: "png",
|
||||
multiplier: 2,
|
||||
});
|
||||
|
||||
// 加载并裁切
|
||||
const img = await loadImage(sourceDataUrl);
|
||||
const { cropRegion } = cropResult;
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropRegion.x,
|
||||
cropRegion.y,
|
||||
cropRegion.width,
|
||||
cropRegion.height,
|
||||
0,
|
||||
0,
|
||||
sizeSpec.width,
|
||||
sizeSpec.height,
|
||||
);
|
||||
|
||||
// 导出
|
||||
const dataUrl = tempCanvas.toDataURL(`image/${format}`, quality / 100);
|
||||
|
||||
// 计算文件大小
|
||||
const base64Length = dataUrl.split(",")[1]?.length || 0;
|
||||
const fileSize = Math.round((base64Length * 3) / 4);
|
||||
|
||||
// 检查合规性
|
||||
const compliance = checkFileCompliance(
|
||||
platform,
|
||||
fileSize / 1024,
|
||||
format,
|
||||
);
|
||||
|
||||
if (!compliance.valid) {
|
||||
return {
|
||||
success: false,
|
||||
platform,
|
||||
sizeName: sizeSpec.name,
|
||||
error: compliance.errors.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
platform,
|
||||
sizeName: sizeSpec.name,
|
||||
filePath: dataUrl,
|
||||
fileSize,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
platform,
|
||||
sizeName: sizeSpec.name,
|
||||
error: err instanceof Error ? err.message : "导出失败",
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量导出
|
||||
*/
|
||||
const exportBatch = useCallback(
|
||||
async (
|
||||
canvas: fabric.Canvas,
|
||||
config: BatchExportConfig,
|
||||
): Promise<ExportResult[]> => {
|
||||
setIsExporting(true);
|
||||
setError(null);
|
||||
setResults([]);
|
||||
|
||||
const { configs } = config;
|
||||
const exportResults: ExportResult[] = [];
|
||||
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: configs.length,
|
||||
});
|
||||
|
||||
for (let i = 0; i < configs.length; i++) {
|
||||
const exportConfig = configs[i];
|
||||
const platformSpec = getPlatformSpec(exportConfig.platform);
|
||||
|
||||
setProgress({
|
||||
current: i + 1,
|
||||
total: configs.length,
|
||||
currentPlatform: platformSpec?.name,
|
||||
currentSize: exportConfig.sizeSpec.name,
|
||||
});
|
||||
|
||||
const result = await exportSingle(canvas, exportConfig);
|
||||
exportResults.push(result);
|
||||
}
|
||||
|
||||
setResults(exportResults);
|
||||
setProgress(null);
|
||||
setIsExporting(false);
|
||||
|
||||
return exportResults;
|
||||
},
|
||||
[exportSingle],
|
||||
);
|
||||
|
||||
/**
|
||||
* 预览导出效果
|
||||
*/
|
||||
const previewExportFn = useCallback(
|
||||
async (
|
||||
canvas: fabric.Canvas,
|
||||
platformId: PlatformId,
|
||||
sizeSpec: SizeSpec,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const sourceWidth = canvas.getWidth();
|
||||
const sourceHeight = canvas.getHeight();
|
||||
|
||||
const cropResult = calculateSmartCrop({
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
targetSpec: sizeSpec,
|
||||
strategy: "smart",
|
||||
});
|
||||
|
||||
const sourceDataUrl = canvas.toDataURL({
|
||||
format: "png",
|
||||
multiplier: 1,
|
||||
});
|
||||
|
||||
return await previewCrop(sourceDataUrl, cropResult, sizeSpec);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "预览失败");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
platforms: allPlatformSpecs,
|
||||
exportSingle,
|
||||
exportBatch,
|
||||
previewExport: previewExportFn,
|
||||
checkCompliance: checkFileCompliance,
|
||||
getPlatform: getPlatformSpec,
|
||||
progress,
|
||||
isExporting,
|
||||
results,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片
|
||||
*/
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("图片加载失败"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default useMultiPlatformExport;
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @file usePosterMaterial.ts
|
||||
* @description 海报素材管理 Hook,提供海报素材的 CRUD 和筛选功能
|
||||
* @module hooks/usePosterMaterial
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
PosterMaterial,
|
||||
PosterMaterialMetadata,
|
||||
CreatePosterMetadataRequest,
|
||||
PosterMaterialFilter,
|
||||
ImageCategory,
|
||||
LayoutCategory,
|
||||
ColorMood,
|
||||
} from "@/types/poster-material";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UsePosterMaterialReturn {
|
||||
/** 海报素材列表 */
|
||||
materials: PosterMaterial[];
|
||||
/** 筛选后的素材列表 */
|
||||
filteredMaterials: PosterMaterial[];
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 当前筛选条件 */
|
||||
filter: PosterMaterialFilter;
|
||||
/** 设置筛选条件 */
|
||||
setFilter: (filter: PosterMaterialFilter) => void;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 获取单个海报素材 */
|
||||
get: (materialId: string) => Promise<PosterMaterial | null>;
|
||||
/** 创建海报素材元数据 */
|
||||
createMetadata: (
|
||||
request: CreatePosterMetadataRequest,
|
||||
) => Promise<PosterMaterialMetadata>;
|
||||
/** 更新海报素材元数据 */
|
||||
updateMetadata: (
|
||||
materialId: string,
|
||||
request: CreatePosterMetadataRequest,
|
||||
) => Promise<PosterMaterialMetadata>;
|
||||
/** 删除海报素材元数据 */
|
||||
deleteMetadata: (materialId: string) => Promise<void>;
|
||||
/** 按图片分类获取素材 */
|
||||
listByImageCategory: (category?: ImageCategory) => Promise<PosterMaterial[]>;
|
||||
/** 按布局分类获取素材 */
|
||||
listByLayoutCategory: (
|
||||
category?: LayoutCategory,
|
||||
) => Promise<PosterMaterial[]>;
|
||||
/** 按配色氛围获取素材 */
|
||||
listByMood: (mood?: ColorMood) => Promise<PosterMaterial[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 海报素材管理 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
* @param initialFilter - 初始筛选条件
|
||||
*/
|
||||
export function usePosterMaterial(
|
||||
projectId: string | null,
|
||||
initialFilter?: PosterMaterialFilter,
|
||||
): UsePosterMaterialReturn {
|
||||
const [materials, setMaterials] = useState<PosterMaterial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<PosterMaterialFilter>(
|
||||
initialFilter || {},
|
||||
);
|
||||
|
||||
/** 刷新素材列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setMaterials([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 根据筛选条件获取不同类型的素材
|
||||
let list: PosterMaterial[] = [];
|
||||
|
||||
if (filter.type === "image") {
|
||||
list = await invoke<PosterMaterial[]>("list_by_image_category", {
|
||||
projectId,
|
||||
category: filter.imageCategory || null,
|
||||
});
|
||||
} else if (filter.type === "layout") {
|
||||
list = await invoke<PosterMaterial[]>("list_by_layout_category", {
|
||||
projectId,
|
||||
category: filter.layoutCategory || null,
|
||||
});
|
||||
} else if (filter.type === "color") {
|
||||
list = await invoke<PosterMaterial[]>("list_by_mood", {
|
||||
projectId,
|
||||
mood: filter.mood || null,
|
||||
});
|
||||
} else {
|
||||
// 获取所有海报素材类型
|
||||
const [images, layouts, colors] = await Promise.all([
|
||||
invoke<PosterMaterial[]>("list_by_image_category", {
|
||||
projectId,
|
||||
category: null,
|
||||
}),
|
||||
invoke<PosterMaterial[]>("list_by_layout_category", {
|
||||
projectId,
|
||||
category: null,
|
||||
}),
|
||||
invoke<PosterMaterial[]>("list_by_mood", {
|
||||
projectId,
|
||||
mood: null,
|
||||
}),
|
||||
]);
|
||||
list = [...images, ...layouts, ...colors];
|
||||
}
|
||||
|
||||
setMaterials(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
projectId,
|
||||
filter.type,
|
||||
filter.imageCategory,
|
||||
filter.layoutCategory,
|
||||
filter.mood,
|
||||
]);
|
||||
|
||||
/** 筛选后的素材列表 */
|
||||
const filteredMaterials = useMemo(() => {
|
||||
let result = materials;
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (filter.query) {
|
||||
const query = filter.query.toLowerCase();
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.description?.toLowerCase().includes(query) ||
|
||||
m.tags.some((tag) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
result = result.filter((m) =>
|
||||
filter.tags!.some((tag) => m.tags.includes(tag)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [materials, filter.query, filter.tags]);
|
||||
|
||||
/** 获取单个海报素材 */
|
||||
const get = useCallback(
|
||||
async (materialId: string): Promise<PosterMaterial | null> => {
|
||||
try {
|
||||
return await invoke<PosterMaterial | null>("get_poster_material", {
|
||||
materialId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 创建海报素材元数据 */
|
||||
const createMetadata = useCallback(
|
||||
async (
|
||||
request: CreatePosterMetadataRequest,
|
||||
): Promise<PosterMaterialMetadata> => {
|
||||
const metadata = await invoke<PosterMaterialMetadata>(
|
||||
"create_poster_metadata",
|
||||
{ req: request },
|
||||
);
|
||||
await refresh();
|
||||
return metadata;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新海报素材元数据 */
|
||||
const updateMetadata = useCallback(
|
||||
async (
|
||||
materialId: string,
|
||||
request: CreatePosterMetadataRequest,
|
||||
): Promise<PosterMaterialMetadata> => {
|
||||
const metadata = await invoke<PosterMaterialMetadata>(
|
||||
"update_poster_metadata",
|
||||
{ materialId, req: request },
|
||||
);
|
||||
await refresh();
|
||||
return metadata;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除海报素材元数据 */
|
||||
const deleteMetadata = useCallback(
|
||||
async (materialId: string): Promise<void> => {
|
||||
await invoke("delete_poster_metadata", { materialId });
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 按图片分类获取素材 */
|
||||
const listByImageCategory = useCallback(
|
||||
async (category?: ImageCategory): Promise<PosterMaterial[]> => {
|
||||
if (!projectId) return [];
|
||||
return invoke<PosterMaterial[]>("list_by_image_category", {
|
||||
projectId,
|
||||
category: category || null,
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
/** 按布局分类获取素材 */
|
||||
const listByLayoutCategory = useCallback(
|
||||
async (category?: LayoutCategory): Promise<PosterMaterial[]> => {
|
||||
if (!projectId) return [];
|
||||
return invoke<PosterMaterial[]>("list_by_layout_category", {
|
||||
projectId,
|
||||
category: category || null,
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
/** 按配色氛围获取素材 */
|
||||
const listByMood = useCallback(
|
||||
async (mood?: ColorMood): Promise<PosterMaterial[]> => {
|
||||
if (!projectId) return [];
|
||||
return invoke<PosterMaterial[]>("list_by_mood", {
|
||||
projectId,
|
||||
mood: mood || null,
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
materials,
|
||||
filteredMaterials,
|
||||
loading,
|
||||
error,
|
||||
filter,
|
||||
setFilter,
|
||||
refresh,
|
||||
get,
|
||||
createMetadata,
|
||||
updateMetadata,
|
||||
deleteMetadata,
|
||||
listByImageCategory,
|
||||
listByLayoutCategory,
|
||||
listByMood,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePosterMaterial;
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file usePosterWorkflow.ts
|
||||
* @description 海报工作流 Hook
|
||||
* @module hooks/usePosterWorkflow
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
getWorkflowTemplate,
|
||||
allWorkflowTemplates,
|
||||
type WorkflowTemplate,
|
||||
type WorkflowStep,
|
||||
type WorkflowStepStatus,
|
||||
type WorkflowStepState,
|
||||
type WorkflowConfig,
|
||||
type WorkflowCallbacks,
|
||||
type WorkflowContext,
|
||||
} from "@/components/content-creator/workflows/poster";
|
||||
import {
|
||||
posterAgentScheduler,
|
||||
type AgentInput,
|
||||
type AgentOutput,
|
||||
} from "@/components/content-creator/agents";
|
||||
|
||||
/**
|
||||
* 工作流 Hook 返回值
|
||||
*/
|
||||
export interface UsePosterWorkflowReturn {
|
||||
/** 当前工作流模板 */
|
||||
currentWorkflow: WorkflowTemplate | null;
|
||||
/** 当前步骤索引 */
|
||||
currentStepIndex: number;
|
||||
/** 当前步骤 */
|
||||
currentStep: WorkflowStep | null;
|
||||
/** 各步骤状态 */
|
||||
stepStates: Map<string, WorkflowStepState>;
|
||||
/** 是否正在执行 */
|
||||
isExecuting: boolean;
|
||||
/** 是否已完成 */
|
||||
isCompleted: boolean;
|
||||
/** 总体进度 (0-100) */
|
||||
progress: number;
|
||||
/** 执行结果 */
|
||||
results: Map<string, AgentOutput>;
|
||||
/** 所有可用工作流 */
|
||||
availableWorkflows: WorkflowTemplate[];
|
||||
/** 开始工作流 */
|
||||
startWorkflow: (templateId: string, config?: WorkflowConfig) => void;
|
||||
/** 执行当前步骤 */
|
||||
executeCurrentStep: (context: WorkflowContext) => Promise<AgentOutput | null>;
|
||||
/** 跳过当前步骤 */
|
||||
skipCurrentStep: () => void;
|
||||
/** 前往下一步 */
|
||||
goToNextStep: () => void;
|
||||
/** 返回上一步 */
|
||||
goToPreviousStep: () => void;
|
||||
/** 跳转到指定步骤 */
|
||||
goToStep: (stepIndex: number) => void;
|
||||
/** 重置工作流 */
|
||||
resetWorkflow: () => void;
|
||||
/** 获取步骤状态 */
|
||||
getStepStatus: (stepId: string) => WorkflowStepStatus;
|
||||
/** 设置回调 */
|
||||
setCallbacks: (callbacks: WorkflowCallbacks) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 海报工作流 Hook
|
||||
*
|
||||
* 管理海报设计工作流的状态和执行。
|
||||
*/
|
||||
export function usePosterWorkflow(): UsePosterWorkflowReturn {
|
||||
// 状态
|
||||
const [currentWorkflow, setCurrentWorkflow] =
|
||||
useState<WorkflowTemplate | null>(null);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [stepStates, setStepStates] = useState<Map<string, WorkflowStepState>>(
|
||||
new Map(),
|
||||
);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [results, setResults] = useState<Map<string, AgentOutput>>(new Map());
|
||||
const [callbacks, setCallbacks] = useState<WorkflowCallbacks>({});
|
||||
|
||||
// 计算属性
|
||||
const currentStep = useMemo(() => {
|
||||
if (!currentWorkflow) return null;
|
||||
return currentWorkflow.steps[currentStepIndex] || null;
|
||||
}, [currentWorkflow, currentStepIndex]);
|
||||
|
||||
const isCompleted = useMemo(() => {
|
||||
if (!currentWorkflow) return false;
|
||||
return currentStepIndex >= currentWorkflow.steps.length;
|
||||
}, [currentWorkflow, currentStepIndex]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!currentWorkflow || currentWorkflow.steps.length === 0) return 0;
|
||||
const completedSteps = Array.from(stepStates.values()).filter(
|
||||
(s) => s.status === "completed" || s.status === "skipped",
|
||||
).length;
|
||||
return Math.round((completedSteps / currentWorkflow.steps.length) * 100);
|
||||
}, [currentWorkflow, stepStates]);
|
||||
|
||||
// 更新步骤状态
|
||||
const updateStepState = useCallback(
|
||||
(stepId: string, updates: Partial<WorkflowStepState>) => {
|
||||
setStepStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const current = newMap.get(stepId) || { stepId, status: "pending" };
|
||||
newMap.set(stepId, { ...current, ...updates });
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 开始工作流
|
||||
const startWorkflow = useCallback(
|
||||
(templateId: string, _config?: WorkflowConfig) => {
|
||||
const template = getWorkflowTemplate(templateId);
|
||||
if (!template) {
|
||||
console.error(`[usePosterWorkflow] Workflow not found: ${templateId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentWorkflow(template);
|
||||
setCurrentStepIndex(0);
|
||||
setResults(new Map());
|
||||
|
||||
// 初始化步骤状态
|
||||
const initialStates = new Map<string, WorkflowStepState>();
|
||||
template.steps.forEach((step, index) => {
|
||||
initialStates.set(step.id, {
|
||||
stepId: step.id,
|
||||
status: index === 0 ? "active" : "pending",
|
||||
});
|
||||
});
|
||||
setStepStates(initialStates);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 执行当前步骤
|
||||
const executeCurrentStep = useCallback(
|
||||
async (context: WorkflowContext): Promise<AgentOutput | null> => {
|
||||
if (!currentStep || !currentWorkflow) return null;
|
||||
|
||||
setIsExecuting(true);
|
||||
updateStepState(currentStep.id, {
|
||||
status: "active",
|
||||
startedAt: new Date(),
|
||||
});
|
||||
callbacks.onStepStart?.(currentStep);
|
||||
|
||||
try {
|
||||
// 构建 Agent 输入
|
||||
const agentInput: AgentInput = {
|
||||
userInput: context.userInput,
|
||||
context: {
|
||||
projectId: context.projectId,
|
||||
brandPersonaId: context.brandPersonaId,
|
||||
canvasJson: context.canvasJson,
|
||||
selectedMaterials: context.selectedMaterials,
|
||||
targetPlatforms: context.targetPlatforms,
|
||||
workflowId: currentWorkflow.id,
|
||||
stepId: currentStep.id,
|
||||
previousResults: Object.fromEntries(results),
|
||||
...context.extra,
|
||||
},
|
||||
};
|
||||
|
||||
// 执行 Agent
|
||||
const output = await posterAgentScheduler.runAgent(
|
||||
currentStep.agentId,
|
||||
agentInput,
|
||||
);
|
||||
|
||||
if (output) {
|
||||
// 更新结果
|
||||
setResults((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(currentStep.id, output);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// 更新状态
|
||||
updateStepState(currentStep.id, {
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
result: output,
|
||||
});
|
||||
|
||||
callbacks.onStepComplete?.(currentStep, output);
|
||||
callbacks.onProgressUpdate?.(progress, currentStep);
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
updateStepState(currentStep.id, {
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
callbacks.onStepError?.(currentStep, errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentStep,
|
||||
currentWorkflow,
|
||||
results,
|
||||
progress,
|
||||
callbacks,
|
||||
updateStepState,
|
||||
],
|
||||
);
|
||||
|
||||
// 跳过当前步骤
|
||||
const skipCurrentStep = useCallback(() => {
|
||||
if (!currentStep || !currentStep.optional) return;
|
||||
|
||||
updateStepState(currentStep.id, {
|
||||
status: "skipped",
|
||||
completedAt: new Date(),
|
||||
});
|
||||
callbacks.onStepSkip?.(currentStep);
|
||||
|
||||
// 自动前进到下一步
|
||||
goToNextStep();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep, callbacks, updateStepState]);
|
||||
|
||||
// 前往下一步
|
||||
const goToNextStep = useCallback(() => {
|
||||
if (!currentWorkflow) return;
|
||||
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < currentWorkflow.steps.length) {
|
||||
setCurrentStepIndex(nextIndex);
|
||||
updateStepState(currentWorkflow.steps[nextIndex].id, {
|
||||
status: "active",
|
||||
});
|
||||
} else {
|
||||
// 工作流完成
|
||||
callbacks.onWorkflowComplete?.(results);
|
||||
}
|
||||
}, [currentWorkflow, currentStepIndex, results, callbacks, updateStepState]);
|
||||
|
||||
// 返回上一步
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
if (currentStepIndex > 0) {
|
||||
setCurrentStepIndex(currentStepIndex - 1);
|
||||
}
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// 跳转到指定步骤
|
||||
const goToStep = useCallback(
|
||||
(stepIndex: number) => {
|
||||
if (!currentWorkflow) return;
|
||||
if (stepIndex >= 0 && stepIndex < currentWorkflow.steps.length) {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
updateStepState(currentWorkflow.steps[stepIndex].id, {
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentWorkflow, updateStepState],
|
||||
);
|
||||
|
||||
// 重置工作流
|
||||
const resetWorkflow = useCallback(() => {
|
||||
if (!currentWorkflow) return;
|
||||
|
||||
setCurrentStepIndex(0);
|
||||
setResults(new Map());
|
||||
|
||||
// 重置步骤状态
|
||||
const resetStates = new Map<string, WorkflowStepState>();
|
||||
currentWorkflow.steps.forEach((step, index) => {
|
||||
resetStates.set(step.id, {
|
||||
stepId: step.id,
|
||||
status: index === 0 ? "active" : "pending",
|
||||
});
|
||||
});
|
||||
setStepStates(resetStates);
|
||||
}, [currentWorkflow]);
|
||||
|
||||
// 获取步骤状态
|
||||
const getStepStatus = useCallback(
|
||||
(stepId: string): WorkflowStepStatus => {
|
||||
return stepStates.get(stepId)?.status || "pending";
|
||||
},
|
||||
[stepStates],
|
||||
);
|
||||
|
||||
return {
|
||||
currentWorkflow,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
stepStates,
|
||||
isExecuting,
|
||||
isCompleted,
|
||||
progress,
|
||||
results,
|
||||
availableWorkflows: allWorkflowTemplates,
|
||||
startWorkflow,
|
||||
executeCurrentStep,
|
||||
skipCurrentStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
goToStep,
|
||||
resetWorkflow,
|
||||
getStepStatus,
|
||||
setCallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePosterWorkflow;
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* A2UI 表单持久化 API
|
||||
*
|
||||
* 提供 A2UI 表单数据的持久化管理,支持:
|
||||
* - 创建表单记录
|
||||
* - 保存用户填写的表单数据
|
||||
* - 提交表单
|
||||
* - 查询表单(按消息/会话)
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* A2UI 表单记录
|
||||
*/
|
||||
export interface A2UIForm {
|
||||
/** 表单唯一 ID */
|
||||
id: string;
|
||||
/** 关联的消息 ID */
|
||||
messageId: number;
|
||||
/** 会话 ID */
|
||||
sessionId: string;
|
||||
/** A2UI 响应 JSON(原始 AI 返回的 A2UI 结构) */
|
||||
a2uiResponseJson: string;
|
||||
/** 用户填写的表单数据 JSON */
|
||||
formDataJson: string | null;
|
||||
/** 表单状态: draft | submitted */
|
||||
status: "draft" | "submitted";
|
||||
/** 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** 更新时间戳 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI 表单 API 类
|
||||
*/
|
||||
export class A2UIFormAPI {
|
||||
/**
|
||||
* 创建 A2UI 表单记录
|
||||
*
|
||||
* @param messageId 关联的消息 ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param a2uiResponseJson A2UI 响应 JSON
|
||||
* @param formDataJson 初始表单数据(可选)
|
||||
*/
|
||||
static async create(
|
||||
messageId: number,
|
||||
sessionId: string,
|
||||
a2uiResponseJson: string,
|
||||
formDataJson?: string,
|
||||
): Promise<A2UIForm> {
|
||||
return invoke("create_a2ui_form", {
|
||||
messageId,
|
||||
sessionId,
|
||||
a2uiResponseJson,
|
||||
formDataJson: formDataJson ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个表单
|
||||
*/
|
||||
static async get(id: string): Promise<A2UIForm | null> {
|
||||
return invoke("get_a2ui_form", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息 ID 获取表单列表
|
||||
*/
|
||||
static async getByMessage(messageId: number): Promise<A2UIForm[]> {
|
||||
return invoke("get_a2ui_forms_by_message", { messageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话 ID 获取所有表单
|
||||
*/
|
||||
static async getBySession(sessionId: string): Promise<A2UIForm[]> {
|
||||
return invoke("get_a2ui_forms_by_session", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存表单数据(用户填写的内容)
|
||||
*
|
||||
* @param id 表单 ID
|
||||
* @param formDataJson 表单数据 JSON
|
||||
*/
|
||||
static async saveFormData(
|
||||
id: string,
|
||||
formDataJson: string,
|
||||
): Promise<A2UIForm> {
|
||||
return invoke("save_a2ui_form_data", { id, formDataJson });
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*
|
||||
* @param id 表单 ID
|
||||
* @param formDataJson 最终表单数据 JSON
|
||||
*/
|
||||
static async submit(id: string, formDataJson: string): Promise<A2UIForm> {
|
||||
return invoke("submit_a2ui_form", { id, formDataJson });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表单
|
||||
*/
|
||||
static async delete(id: string): Promise<void> {
|
||||
return invoke("delete_a2ui_form", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建表单(便捷方法)
|
||||
*
|
||||
* 如果消息已有表单记录则返回,否则创建新记录
|
||||
*/
|
||||
static async getOrCreate(
|
||||
messageId: number,
|
||||
sessionId: string,
|
||||
a2uiResponseJson: string,
|
||||
): Promise<A2UIForm> {
|
||||
const forms = await this.getByMessage(messageId);
|
||||
if (forms.length > 0) {
|
||||
return forms[0];
|
||||
}
|
||||
return this.create(messageId, sessionId, a2uiResponseJson);
|
||||
}
|
||||
}
|
||||
|
||||
export default A2UIFormAPI;
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* 品牌人设相关类型定义
|
||||
*
|
||||
* 定义品牌人设(BrandPersona)相关的 TypeScript 类型,
|
||||
* 用于海报设计系统的品牌视觉规范管理。
|
||||
*
|
||||
* @module types/brand-persona
|
||||
*/
|
||||
|
||||
import type { Persona } from "./persona";
|
||||
|
||||
// ============================================================================
|
||||
// 品牌个性类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 品牌个性类型
|
||||
*/
|
||||
export type BrandPersonality =
|
||||
| "professional" // 专业严谨
|
||||
| "friendly" // 亲切友好
|
||||
| "playful" // 活泼有趣
|
||||
| "luxurious" // 奢华高端
|
||||
| "minimalist" // 简约克制
|
||||
| "bold" // 大胆张扬
|
||||
| "elegant"; // 优雅精致
|
||||
|
||||
/**
|
||||
* 品牌个性显示名称映射
|
||||
*/
|
||||
export const BRAND_PERSONALITY_NAMES: Record<BrandPersonality, string> = {
|
||||
professional: "专业严谨",
|
||||
friendly: "亲切友好",
|
||||
playful: "活泼有趣",
|
||||
luxurious: "奢华高端",
|
||||
minimalist: "简约克制",
|
||||
bold: "大胆张扬",
|
||||
elegant: "优雅精致",
|
||||
};
|
||||
|
||||
/**
|
||||
* 品牌个性描述映射
|
||||
*/
|
||||
export const BRAND_PERSONALITY_DESCRIPTIONS: Record<BrandPersonality, string> =
|
||||
{
|
||||
professional: "传递专业可信的形象",
|
||||
friendly: "拉近与用户的距离",
|
||||
playful: "充满活力和趣味",
|
||||
luxurious: "彰显品质和档次",
|
||||
minimalist: "少即是多的美学",
|
||||
bold: "敢于突破和创新",
|
||||
elegant: "精致细腻的品味",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 设计风格类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 设计风格类型
|
||||
*/
|
||||
export type DesignStyle =
|
||||
| "minimal" // 极简
|
||||
| "modern" // 现代
|
||||
| "classic" // 经典
|
||||
| "playful" // 活泼
|
||||
| "corporate" // 商务
|
||||
| "artistic" // 艺术
|
||||
| "retro"; // 复古
|
||||
|
||||
/**
|
||||
* 设计风格显示名称映射
|
||||
*/
|
||||
export const DESIGN_STYLE_NAMES: Record<DesignStyle, string> = {
|
||||
minimal: "极简",
|
||||
modern: "现代",
|
||||
classic: "经典",
|
||||
playful: "活泼",
|
||||
corporate: "商务",
|
||||
artistic: "艺术",
|
||||
retro: "复古",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 配色方案
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 渐变配置
|
||||
*/
|
||||
export interface GradientConfig {
|
||||
/** 渐变名称 */
|
||||
name: string;
|
||||
/** 渐变颜色列表 */
|
||||
colors: string[];
|
||||
/** 渐变方向(角度) */
|
||||
direction: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配色方案
|
||||
*/
|
||||
export interface ColorScheme {
|
||||
/** 主色 */
|
||||
primary: string;
|
||||
/** 辅色 */
|
||||
secondary: string;
|
||||
/** 强调色 */
|
||||
accent: string;
|
||||
/** 背景色 */
|
||||
background: string;
|
||||
/** 文字色 */
|
||||
text: string;
|
||||
/** 次要文字色 */
|
||||
textSecondary: string;
|
||||
/** 渐变配置 */
|
||||
gradients?: GradientConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设配色方案
|
||||
*/
|
||||
export const PRESET_COLOR_SCHEMES: { name: string; colors: ColorScheme }[] = [
|
||||
{
|
||||
name: "清新粉",
|
||||
colors: {
|
||||
primary: "#FF6B9D",
|
||||
secondary: "#FFC0D0",
|
||||
accent: "#FF4081",
|
||||
background: "#FFFFFF",
|
||||
text: "#333333",
|
||||
textSecondary: "#666666",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "商务蓝",
|
||||
colors: {
|
||||
primary: "#2196F3",
|
||||
secondary: "#90CAF9",
|
||||
accent: "#1976D2",
|
||||
background: "#FFFFFF",
|
||||
text: "#212121",
|
||||
textSecondary: "#757575",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "奢华金",
|
||||
colors: {
|
||||
primary: "#D4AF37",
|
||||
secondary: "#C9A86C",
|
||||
accent: "#FFD700",
|
||||
background: "#0A0A0A",
|
||||
text: "#FFFFFF",
|
||||
textSecondary: "#B0B0B0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "自然绿",
|
||||
colors: {
|
||||
primary: "#4CAF50",
|
||||
secondary: "#A5D6A7",
|
||||
accent: "#2E7D32",
|
||||
background: "#FFFFFF",
|
||||
text: "#1B5E20",
|
||||
textSecondary: "#558B2F",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "活力橙",
|
||||
colors: {
|
||||
primary: "#FF9500",
|
||||
secondary: "#FFD166",
|
||||
accent: "#EF476F",
|
||||
background: "#FFFFFF",
|
||||
text: "#2D3436",
|
||||
textSecondary: "#636E72",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "电商红",
|
||||
colors: {
|
||||
primary: "#FF4757",
|
||||
secondary: "#FFA502",
|
||||
accent: "#FF6348",
|
||||
background: "#FFFFFF",
|
||||
text: "#2F3542",
|
||||
textSecondary: "#57606F",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 字体方案
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 字体方案
|
||||
*/
|
||||
export interface Typography {
|
||||
/** 标题字体 */
|
||||
titleFont: string;
|
||||
/** 标题字重 */
|
||||
titleWeight: number;
|
||||
/** 正文字体 */
|
||||
bodyFont: string;
|
||||
/** 正文字重 */
|
||||
bodyWeight: number;
|
||||
/** 标题字号基准 */
|
||||
titleSize: number;
|
||||
/** 正文字号基准 */
|
||||
bodySize: number;
|
||||
/** 行高 */
|
||||
lineHeight: number;
|
||||
/** 字间距 */
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用字体列表
|
||||
*/
|
||||
export const AVAILABLE_FONTS = [
|
||||
{ id: "source-han-sans", name: "思源黑体" },
|
||||
{ id: "source-han-serif", name: "思源宋体" },
|
||||
{ id: "pingfang", name: "苹方" },
|
||||
{ id: "alibaba-puhuiti", name: "阿里巴巴普惠体" },
|
||||
{ id: "zcool-kuaile", name: "站酷快乐体" },
|
||||
{ id: "zcool-qingke", name: "站酷庆科黄油体" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 默认字体方案
|
||||
*/
|
||||
export const DEFAULT_TYPOGRAPHY: Typography = {
|
||||
titleFont: "思源黑体",
|
||||
titleWeight: 700,
|
||||
bodyFont: "苹方",
|
||||
bodyWeight: 400,
|
||||
titleSize: 72,
|
||||
bodySize: 24,
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: 0,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 视觉规范
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Logo 位置类型
|
||||
*/
|
||||
export type LogoPosition =
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "center";
|
||||
|
||||
/**
|
||||
* Logo 位置配置
|
||||
*/
|
||||
export interface LogoPlacement {
|
||||
/** 默认位置 */
|
||||
defaultPosition: LogoPosition;
|
||||
/** 内边距 */
|
||||
padding: number;
|
||||
/** 最大尺寸(百分比) */
|
||||
maxSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片风格配置
|
||||
*/
|
||||
export interface ImageStyle {
|
||||
/** CSS 滤镜 */
|
||||
filter?: string;
|
||||
/** 圆角 */
|
||||
borderRadius: number;
|
||||
/** 阴影 */
|
||||
shadow?: string;
|
||||
/** 偏好比例 */
|
||||
preferredRatio: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图标风格类型
|
||||
*/
|
||||
export type IconStyleType = "filled" | "outlined" | "rounded";
|
||||
|
||||
/**
|
||||
* 图标风格配置
|
||||
*/
|
||||
export interface IconStyle {
|
||||
/** 风格类型 */
|
||||
style: IconStyleType;
|
||||
/** 描边宽度 */
|
||||
strokeWidth?: number;
|
||||
/** 默认颜色 */
|
||||
defaultColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视觉规范配置
|
||||
*/
|
||||
export interface VisualConfig {
|
||||
/** Logo 图片 URL */
|
||||
logoUrl?: string;
|
||||
/** Logo 位置配置 */
|
||||
logoPlacement: LogoPlacement;
|
||||
/** 图片风格 */
|
||||
imageStyle: ImageStyle;
|
||||
/** 图标风格 */
|
||||
iconStyle: IconStyle;
|
||||
/** 装饰元素列表 */
|
||||
decorations: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 品牌调性
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 品牌调性配置
|
||||
*/
|
||||
export interface BrandTone {
|
||||
/** 品牌关键词 */
|
||||
keywords: string[];
|
||||
/** 品牌个性 */
|
||||
personality: BrandPersonality;
|
||||
/** 品牌语调 */
|
||||
voiceTone?: string;
|
||||
/** 目标受众描述 */
|
||||
targetAudience?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 设计配置
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 设计配置
|
||||
*/
|
||||
export interface DesignConfig {
|
||||
/** 主风格 */
|
||||
primaryStyle: DesignStyle;
|
||||
/** 配色方案 */
|
||||
colorScheme: ColorScheme;
|
||||
/** 字体方案 */
|
||||
typography: Typography;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 品牌人设扩展
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 品牌人设扩展
|
||||
*/
|
||||
export interface BrandPersonaExtension {
|
||||
/** 关联的人设 ID */
|
||||
personaId: string;
|
||||
/** 品牌调性 */
|
||||
brandTone: BrandTone;
|
||||
/** 设计配置 */
|
||||
design: DesignConfig;
|
||||
/** 视觉规范 */
|
||||
visual: VisualConfig;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 更新时间 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌人设(完整视图)
|
||||
*/
|
||||
export interface BrandPersona extends Persona {
|
||||
/** 品牌调性 */
|
||||
brandTone?: BrandTone;
|
||||
/** 设计配置 */
|
||||
design?: DesignConfig;
|
||||
/** 视觉规范 */
|
||||
visual?: VisualConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 请求类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建品牌人设扩展请求
|
||||
*/
|
||||
export interface CreateBrandExtensionRequest {
|
||||
/** 关联的人设 ID */
|
||||
personaId: string;
|
||||
/** 品牌调性 */
|
||||
brandTone?: BrandTone;
|
||||
/** 设计配置 */
|
||||
design?: DesignConfig;
|
||||
/** 视觉规范 */
|
||||
visual?: VisualConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新品牌人设扩展请求
|
||||
*/
|
||||
export interface UpdateBrandExtensionRequest {
|
||||
/** 品牌调性 */
|
||||
brandTone?: BrandTone;
|
||||
/** 设计配置 */
|
||||
design?: DesignConfig;
|
||||
/** 视觉规范 */
|
||||
visual?: VisualConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 品牌人设模板
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 品牌人设模板
|
||||
*/
|
||||
export interface BrandPersonaTemplate {
|
||||
/** 模板 ID */
|
||||
id: string;
|
||||
/** 模板名称 */
|
||||
name: string;
|
||||
/** 模板描述 */
|
||||
description: string;
|
||||
/** 品牌调性 */
|
||||
brandTone: BrandTone;
|
||||
/** 设计配置 */
|
||||
design: DesignConfig;
|
||||
/** 视觉规范 */
|
||||
visual?: VisualConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 默认值
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 默认配色方案
|
||||
*/
|
||||
export const DEFAULT_COLOR_SCHEME: ColorScheme = {
|
||||
primary: "#2196F3",
|
||||
secondary: "#90CAF9",
|
||||
accent: "#1976D2",
|
||||
background: "#FFFFFF",
|
||||
text: "#212121",
|
||||
textSecondary: "#757575",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认 Logo 位置配置
|
||||
*/
|
||||
export const DEFAULT_LOGO_PLACEMENT: LogoPlacement = {
|
||||
defaultPosition: "top-left",
|
||||
padding: 20,
|
||||
maxSize: 15,
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认图片风格
|
||||
*/
|
||||
export const DEFAULT_IMAGE_STYLE: ImageStyle = {
|
||||
borderRadius: 8,
|
||||
preferredRatio: "3:4",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认图标风格
|
||||
*/
|
||||
export const DEFAULT_ICON_STYLE: IconStyle = {
|
||||
style: "outlined",
|
||||
strokeWidth: 2,
|
||||
defaultColor: "#333333",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认视觉配置
|
||||
*/
|
||||
export const DEFAULT_VISUAL_CONFIG: VisualConfig = {
|
||||
logoPlacement: DEFAULT_LOGO_PLACEMENT,
|
||||
imageStyle: DEFAULT_IMAGE_STYLE,
|
||||
iconStyle: DEFAULT_ICON_STYLE,
|
||||
decorations: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认品牌调性
|
||||
*/
|
||||
export const DEFAULT_BRAND_TONE: BrandTone = {
|
||||
keywords: [],
|
||||
personality: "professional",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认设计配置
|
||||
*/
|
||||
export const DEFAULT_DESIGN_CONFIG: DesignConfig = {
|
||||
primaryStyle: "modern",
|
||||
colorScheme: DEFAULT_COLOR_SCHEME,
|
||||
typography: DEFAULT_TYPOGRAPHY,
|
||||
};
|
||||
@@ -13,9 +13,15 @@ export * from "./project";
|
||||
// 人设类型
|
||||
export * from "./persona";
|
||||
|
||||
// 品牌人设类型
|
||||
export * from "./brand-persona";
|
||||
|
||||
// 素材类型
|
||||
export * from "./material";
|
||||
|
||||
// 海报素材类型
|
||||
export * from "./poster-material";
|
||||
|
||||
// 模板类型
|
||||
export * from "./template";
|
||||
|
||||
|
||||
+12
-1
@@ -14,7 +14,15 @@
|
||||
/**
|
||||
* 素材类型枚举
|
||||
*/
|
||||
export type MaterialType = "document" | "image" | "text" | "data" | "link";
|
||||
export type MaterialType =
|
||||
| "document"
|
||||
| "image"
|
||||
| "text"
|
||||
| "data"
|
||||
| "link"
|
||||
| "icon"
|
||||
| "color"
|
||||
| "layout";
|
||||
|
||||
/**
|
||||
* 素材类型显示名称映射
|
||||
@@ -25,6 +33,9 @@ export const MaterialTypeLabels: Record<MaterialType, string> = {
|
||||
text: "文本",
|
||||
data: "数据",
|
||||
link: "链接",
|
||||
icon: "图标",
|
||||
color: "配色",
|
||||
layout: "布局",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user