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:
coso
2026-02-04 20:34:49 +08:00
co-authored by Warp
parent 322b2e07f8
commit 174ae555db
101 changed files with 16991 additions and 2217 deletions
+13 -25
View File
@@ -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
+105
View File
@@ -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
+186
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.56.0",
"version": "0.57.0",
"type": "module",
"repository": {
"type": "git",
+16 -16
View File
@@ -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]]
+4 -2
View File
@@ -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 = [] # 预留特性:系统通知功能
+7 -2
View File
@@ -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"
+2
View File
@@ -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::*;
+25
View File
@@ -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,
+107
View File
@@ -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)
}
+2
View File
@@ -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;
+360 -14
View File
@@ -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())
}
+280
View File
@@ -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());
}
}
+3
View File
@@ -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());
}
}
+105
View File
@@ -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(())
}
+863
View File
@@ -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);
}
}
+286 -1
View File
@@ -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");
}
}
+17 -1
View File
@@ -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 -1
View File
@@ -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,
};
}
+38 -5
View File
@@ -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";
+1 -1
View File
@@ -30,7 +30,7 @@ export type {
// 其他类型
ChoiceOption,
ButtonAction,
ValidationRule,
CheckRule,
DynamicValue,
DynamicString,
DynamicBoolean,
+464 -33
View File
@@ -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>
+2
View File
@@ -5,5 +5,7 @@
*/
export { PersonaDialog } from "./PersonaDialog";
export { BrandPersonaDialog } from "./BrandPersonaDialog";
export { TemplateDialog } from "./TemplateDialog";
export { MaterialUploadDialog } from "./MaterialUploadDialog";
export { MaterialPreviewDialog } from "./MaterialPreviewDialog";
+25 -3
View File
@@ -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>
);
}
+4
View File
@@ -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 {
+188
View File
@@ -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;
+284
View File
@@ -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;
+277
View File
@@ -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;
+320
View File
@@ -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;
+130
View File
@@ -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;
+504
View File
@@ -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,
};
+6
View File
@@ -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
View File
@@ -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