feat: 实现模特管理功能
- 新增模特数据模型和数据库表结构 - 实现模特的完整CRUD操作 - 添加模特照片管理功能 - 实现素材与模特关联功能 - 创建模特管理前端界面 - 集成到主应用导航和路由 - 修复数据库连接死锁问题 功能特性: - 模特基本信息管理(姓名、艺名、性别、年龄等) - 照片管理和封面设置 - 标签系统 - 状态管理(活跃、不活跃、退役、暂停) - 评分系统 - 搜索和过滤功能 - 素材关联功能
This commit is contained in:
48
0.1.1.md
48
0.1.1.md
@@ -41,10 +41,56 @@
|
||||
根据promptx\frontend-developer规定的前端开发规范 优化现有UI和UX操作体验
|
||||
要求界面美观 操作流畅 动画优美 合理化信息展示及布局 符合用户操作习惯和大众审美习惯
|
||||
|
||||
|
||||
## BUG
|
||||
- 切换到 hotfix 分支修复: ffmpeg和ffmprobe执行时 会有命令行闪现
|
||||
|
||||
|
||||
## 0.1.6 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
|
||||
开发 模特管理 功能
|
||||
1. 实现模特的curd功能
|
||||
2. 素材可以和模特关联
|
||||
|
||||
- 再次学习 promptx\tauri-desktop-app-expert tauri开发规范
|
||||
- 再次学习 promptx\frontend-developer 前端开发规范
|
||||
|
||||
## 0.1.7 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
开发 AI分类设置 页面
|
||||
1. 实现AI分类的curd
|
||||
2. 核心功能:定义分类名/给大模型用的提示词{什么样的视频是这个分类}
|
||||
|
||||
## 0.1.8 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
开发 切片视频 AI自动归类整理 素材功能
|
||||
1. 详情页面 添加 素材智能分类按钮 点击后 调用gemini 自动给视频片段分类
|
||||
|
||||
## 0.1.9 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
开发 模板导入功能
|
||||
1. 导入 剪映草稿模板 分析轨道片段信息 (时长/material_id/segment_id/track_id/name)等信息
|
||||
2. 编辑 name 信息
|
||||
|
||||
## 0.1.10 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
开发 素材模板匹配功能
|
||||
### 核心规则
|
||||
1. 每个素材最多使用一次
|
||||
2. 尽量多的使用模板 保证视频的多样性
|
||||
3. 根据模板片段中的name信息 匹配 视频AI分类 且 每个模板只能使用 同一个模特的素材 模板可多次使用
|
||||
4. 匹配成功的模板 生成 匹配记录/素材标记为已使用
|
||||
|
||||
## 0.1.10 核心功能开发
|
||||
新建feature分支完成一下功能开发:
|
||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||
开发 导出 匹配结果到剪映草稿箱
|
||||
|
||||
### 优化
|
||||
提交代码 然后优化:
|
||||
feature: 编写单元测试和集成测试
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2173,7 +2173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mixvideo-desktop"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -15,32 +15,33 @@
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"zustand": "^4.4.7",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0"
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"vitest": "^1.0.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"jsdom": "^23.0.1"
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::data::repositories::project_repository::ProjectRepository;
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::data::repositories::model_repository::ModelRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use crate::infrastructure::performance::PerformanceMonitor;
|
||||
use crate::infrastructure::event_bus::EventBusManager;
|
||||
@@ -11,6 +12,7 @@ pub struct AppState {
|
||||
pub database: Mutex<Option<Database>>,
|
||||
pub project_repository: Mutex<Option<ProjectRepository>>,
|
||||
pub material_repository: Mutex<Option<MaterialRepository>>,
|
||||
pub model_repository: Mutex<Option<ModelRepository>>,
|
||||
pub performance_monitor: Mutex<PerformanceMonitor>,
|
||||
pub event_bus_manager: Arc<EventBusManager>,
|
||||
}
|
||||
@@ -21,6 +23,7 @@ impl AppState {
|
||||
database: Mutex::new(None),
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
}
|
||||
@@ -32,10 +35,12 @@ impl AppState {
|
||||
let database = Database::new()?;
|
||||
let project_repository = ProjectRepository::new(database.get_connection())?;
|
||||
let material_repository = MaterialRepository::new(database.get_connection())?;
|
||||
let model_repository = ModelRepository::new(database.get_connection());
|
||||
|
||||
*self.database.lock().unwrap() = Some(database);
|
||||
*self.project_repository.lock().unwrap() = Some(project_repository);
|
||||
*self.material_repository.lock().unwrap() = Some(material_repository);
|
||||
*self.model_repository.lock().unwrap() = Some(model_repository);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,6 +54,11 @@ impl AppState {
|
||||
pub fn get_material_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<MaterialRepository>>> {
|
||||
Ok(self.material_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取模特仓库实例
|
||||
pub fn get_model_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ModelRepository>>> {
|
||||
Ok(self.model_repository.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod project_service;
|
||||
pub mod material_service;
|
||||
pub mod model_service;
|
||||
pub mod async_material_service;
|
||||
|
||||
465
apps/desktop/src-tauri/src/business/services/model_service.rs
Normal file
465
apps/desktop/src-tauri/src/business/services/model_service.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use crate::data::models::model::{
|
||||
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
|
||||
ModelQueryParams, Gender, PhotoType, ModelStatus
|
||||
};
|
||||
use crate::data::repositories::model_repository::ModelRepository;
|
||||
|
||||
/// 模特业务服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct ModelService;
|
||||
|
||||
impl ModelService {
|
||||
/// 创建模特
|
||||
pub fn create_model(
|
||||
repository: &ModelRepository,
|
||||
request: CreateModelRequest,
|
||||
) -> Result<Model> {
|
||||
// 创建模特实例
|
||||
let mut model = Model::new(request.name, request.gender);
|
||||
|
||||
// 设置可选字段
|
||||
if let Some(stage_name) = request.stage_name {
|
||||
model.stage_name = Some(stage_name);
|
||||
}
|
||||
|
||||
if let Some(age) = request.age {
|
||||
model.age = Some(age);
|
||||
}
|
||||
|
||||
if let Some(height) = request.height {
|
||||
model.height = Some(height);
|
||||
}
|
||||
|
||||
if let Some(weight) = request.weight {
|
||||
model.weight = Some(weight);
|
||||
}
|
||||
|
||||
if let Some(measurements) = request.measurements {
|
||||
model.measurements = Some(measurements);
|
||||
}
|
||||
|
||||
if let Some(description) = request.description {
|
||||
model.description = Some(description);
|
||||
}
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
model.tags = tags;
|
||||
}
|
||||
|
||||
if let Some(contact_info) = request.contact_info {
|
||||
model.contact_info = Some(contact_info);
|
||||
}
|
||||
|
||||
if let Some(social_media) = request.social_media {
|
||||
model.social_media = Some(social_media);
|
||||
}
|
||||
|
||||
// 验证模特数据
|
||||
model.validate().map_err(|e| anyhow!("验证失败: {}", e))?;
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&model)
|
||||
.map_err(|e| anyhow!("创建模特失败: {}", e))?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// 获取模特详情
|
||||
pub fn get_model_by_id(
|
||||
repository: &ModelRepository,
|
||||
id: &str,
|
||||
) -> Result<Option<Model>> {
|
||||
repository.get_by_id(id)
|
||||
.map_err(|e| anyhow!("获取模特详情失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取所有模特
|
||||
pub fn get_all_models(
|
||||
repository: &ModelRepository,
|
||||
) -> Result<Vec<Model>> {
|
||||
println!("ModelService::get_all_models 开始执行");
|
||||
let result = repository.get_all()
|
||||
.map_err(|e| {
|
||||
println!("repository.get_all() 失败: {}", e);
|
||||
anyhow!("获取模特列表失败: {}", e)
|
||||
});
|
||||
println!("ModelService::get_all_models 执行完成,结果: {:?}", result.is_ok());
|
||||
result
|
||||
}
|
||||
|
||||
/// 搜索模特
|
||||
pub fn search_models(
|
||||
repository: &ModelRepository,
|
||||
params: ModelQueryParams,
|
||||
) -> Result<Vec<Model>> {
|
||||
repository.search(¶ms)
|
||||
.map_err(|e| anyhow!("搜索模特失败: {}", e))
|
||||
}
|
||||
|
||||
/// 更新模特信息
|
||||
pub fn update_model(
|
||||
repository: &ModelRepository,
|
||||
id: &str,
|
||||
request: UpdateModelRequest,
|
||||
) -> Result<Model> {
|
||||
// 获取现有模特
|
||||
let mut model = repository.get_by_id(id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", id))?;
|
||||
|
||||
// 更新字段
|
||||
if let Some(name) = request.name {
|
||||
model.name = name;
|
||||
}
|
||||
|
||||
if request.stage_name.is_some() {
|
||||
model.stage_name = request.stage_name;
|
||||
}
|
||||
|
||||
if request.age.is_some() {
|
||||
model.age = request.age;
|
||||
}
|
||||
|
||||
if request.height.is_some() {
|
||||
model.height = request.height;
|
||||
}
|
||||
|
||||
if request.weight.is_some() {
|
||||
model.weight = request.weight;
|
||||
}
|
||||
|
||||
if request.measurements.is_some() {
|
||||
model.measurements = request.measurements;
|
||||
}
|
||||
|
||||
if request.description.is_some() {
|
||||
model.description = request.description;
|
||||
}
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
model.tags = tags;
|
||||
}
|
||||
|
||||
if request.contact_info.is_some() {
|
||||
model.contact_info = request.contact_info;
|
||||
}
|
||||
|
||||
if request.social_media.is_some() {
|
||||
model.social_media = request.social_media;
|
||||
}
|
||||
|
||||
if let Some(status) = request.status {
|
||||
model.status = status;
|
||||
}
|
||||
|
||||
if request.rating.is_some() {
|
||||
model.rating = request.rating;
|
||||
}
|
||||
|
||||
// 更新时间戳
|
||||
model.updated_at = chrono::Utc::now();
|
||||
|
||||
// 验证更新后的数据
|
||||
model.validate().map_err(|e| anyhow!("验证失败: {}", e))?;
|
||||
|
||||
// 保存到数据库
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("更新模特失败: {}", e))?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// 删除模特
|
||||
pub fn delete_model(
|
||||
repository: &ModelRepository,
|
||||
id: &str,
|
||||
) -> Result<()> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", id))?;
|
||||
|
||||
// 软删除模特
|
||||
repository.delete(id)
|
||||
.map_err(|e| anyhow!("删除模特失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 永久删除模特
|
||||
pub fn delete_model_permanently(
|
||||
repository: &ModelRepository,
|
||||
id: &str,
|
||||
) -> Result<()> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", id))?;
|
||||
|
||||
// 物理删除模特
|
||||
repository.delete_permanently(id)
|
||||
.map_err(|e| anyhow!("永久删除模特失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 添加模特照片
|
||||
pub fn add_model_photo(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
file_path: String,
|
||||
photo_type: PhotoType,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<ModelPhoto> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 验证文件路径
|
||||
let path = Path::new(&file_path);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("文件不存在: {}", file_path));
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
let metadata = std::fs::metadata(&file_path)
|
||||
.map_err(|e| anyhow!("获取文件信息失败: {}", e))?;
|
||||
|
||||
let file_size = metadata.len();
|
||||
let file_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// 创建照片对象
|
||||
let mut photo = ModelPhoto::new(
|
||||
model_id.to_string(),
|
||||
file_path,
|
||||
file_name,
|
||||
file_size,
|
||||
photo_type,
|
||||
);
|
||||
|
||||
if let Some(desc) = description {
|
||||
photo.description = Some(desc);
|
||||
}
|
||||
|
||||
if let Some(photo_tags) = tags {
|
||||
photo.tags = photo_tags;
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
repository.add_photo(&photo)
|
||||
.map_err(|e| anyhow!("添加照片失败: {}", e))?;
|
||||
|
||||
Ok(photo)
|
||||
}
|
||||
|
||||
/// 删除模特照片
|
||||
pub fn delete_model_photo(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
photo_id: &str,
|
||||
) -> Result<()> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 检查照片是否属于该模特
|
||||
let photos = repository.get_photos(model_id)?;
|
||||
let _photo = photos.iter().find(|p| p.id == photo_id)
|
||||
.ok_or_else(|| anyhow!("照片不存在或不属于该模特"))?;
|
||||
|
||||
// 删除照片记录
|
||||
repository.delete_photo(photo_id)
|
||||
.map_err(|e| anyhow!("删除照片失败: {}", e))?;
|
||||
|
||||
// TODO: 删除实际文件(可选)
|
||||
// std::fs::remove_file(&photo.file_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置封面照片
|
||||
pub fn set_cover_photo(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
photo_id: &str,
|
||||
) -> Result<()> {
|
||||
// 检查模特是否存在
|
||||
let _model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 检查照片是否属于该模特
|
||||
let photos = repository.get_photos(model_id)?;
|
||||
let _photo = photos.iter().find(|p| p.id == photo_id)
|
||||
.ok_or_else(|| anyhow!("照片不存在或不属于该模特"))?;
|
||||
|
||||
// 设置封面照片
|
||||
repository.set_cover_photo(model_id, photo_id)
|
||||
.map_err(|e| anyhow!("设置封面照片失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 添加标签
|
||||
pub fn add_tag(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
tag: String,
|
||||
) -> Result<()> {
|
||||
// 获取模特
|
||||
let mut model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 添加标签
|
||||
model.add_tag(tag);
|
||||
|
||||
// 保存更新
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("添加标签失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 移除标签
|
||||
pub fn remove_tag(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
tag: &str,
|
||||
) -> Result<()> {
|
||||
// 获取模特
|
||||
let mut model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 移除标签
|
||||
model.remove_tag(tag);
|
||||
|
||||
// 保存更新
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("移除标签失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新模特状态
|
||||
pub fn update_model_status(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
status: ModelStatus,
|
||||
) -> Result<()> {
|
||||
// 获取模特
|
||||
let mut model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 更新状态
|
||||
model.update_status(status);
|
||||
|
||||
// 保存更新
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("更新状态失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置模特评分
|
||||
pub fn set_model_rating(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
rating: Option<f32>,
|
||||
) -> Result<()> {
|
||||
// 获取模特
|
||||
let mut model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 设置评分
|
||||
model.set_rating(rating);
|
||||
|
||||
// 保存更新
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("设置评分失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置头像
|
||||
pub fn set_avatar(
|
||||
repository: &ModelRepository,
|
||||
model_id: &str,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<()> {
|
||||
// 获取模特
|
||||
let mut model = repository.get_by_id(model_id)?
|
||||
.ok_or_else(|| anyhow!("模特不存在: {}", model_id))?;
|
||||
|
||||
// 验证头像文件路径
|
||||
if let Some(ref path) = avatar_path {
|
||||
if !Path::new(path).exists() {
|
||||
return Err(anyhow!("头像文件不存在: {}", path));
|
||||
}
|
||||
}
|
||||
|
||||
// 设置头像
|
||||
model.set_avatar(avatar_path);
|
||||
|
||||
// 保存更新
|
||||
repository.update(&model)
|
||||
.map_err(|e| anyhow!("设置头像失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模特统计信息
|
||||
pub fn get_model_statistics(
|
||||
repository: &ModelRepository,
|
||||
) -> Result<ModelStatistics> {
|
||||
let models = repository.get_all()?;
|
||||
|
||||
let total_count = models.len();
|
||||
let active_count = models.iter().filter(|m| matches!(m.status, ModelStatus::Active)).count();
|
||||
let inactive_count = models.iter().filter(|m| matches!(m.status, ModelStatus::Inactive)).count();
|
||||
let retired_count = models.iter().filter(|m| matches!(m.status, ModelStatus::Retired)).count();
|
||||
let suspended_count = models.iter().filter(|m| matches!(m.status, ModelStatus::Suspended)).count();
|
||||
|
||||
let male_count = models.iter().filter(|m| matches!(m.gender, Gender::Male)).count();
|
||||
let female_count = models.iter().filter(|m| matches!(m.gender, Gender::Female)).count();
|
||||
let other_count = models.iter().filter(|m| matches!(m.gender, Gender::Other)).count();
|
||||
|
||||
let average_rating = if !models.is_empty() {
|
||||
let rated_models: Vec<_> = models.iter().filter_map(|m| m.rating).collect();
|
||||
if !rated_models.is_empty() {
|
||||
Some(rated_models.iter().sum::<f32>() / rated_models.len() as f32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ModelStatistics {
|
||||
total_count,
|
||||
active_count,
|
||||
inactive_count,
|
||||
retired_count,
|
||||
suspended_count,
|
||||
male_count,
|
||||
female_count,
|
||||
other_count,
|
||||
average_rating,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 模特统计信息
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ModelStatistics {
|
||||
pub total_count: usize,
|
||||
pub active_count: usize,
|
||||
pub inactive_count: usize,
|
||||
pub retired_count: usize,
|
||||
pub suspended_count: usize,
|
||||
pub male_count: usize,
|
||||
pub female_count: usize,
|
||||
pub other_count: usize,
|
||||
pub average_rating: Option<f32>,
|
||||
}
|
||||
@@ -104,6 +104,7 @@ pub struct SceneSegment {
|
||||
pub struct Material {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub model_id: Option<String>, // 关联的模特ID
|
||||
pub name: String,
|
||||
pub original_path: String,
|
||||
pub file_size: u64,
|
||||
@@ -223,6 +224,7 @@ impl Material {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
project_id,
|
||||
model_id: None,
|
||||
name,
|
||||
original_path,
|
||||
file_size,
|
||||
@@ -285,6 +287,28 @@ impl Material {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 关联模特
|
||||
pub fn associate_model(&mut self, model_id: String) {
|
||||
self.model_id = Some(model_id);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 取消关联模特
|
||||
pub fn disassociate_model(&mut self) {
|
||||
self.model_id = None;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 检查是否关联了模特
|
||||
pub fn has_associated_model(&self) -> bool {
|
||||
self.model_id.is_some()
|
||||
}
|
||||
|
||||
/// 获取关联的模特ID
|
||||
pub fn get_associated_model_id(&self) -> Option<&String> {
|
||||
self.model_id.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialSegment {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod project;
|
||||
pub mod material;
|
||||
pub mod model;
|
||||
|
||||
390
apps/desktop/src-tauri/src/data/models/model.rs
Normal file
390
apps/desktop/src-tauri/src/data/models/model.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 模特实体模型
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub stage_name: Option<String>, // 艺名
|
||||
pub gender: Gender,
|
||||
pub age: Option<u32>,
|
||||
pub height: Option<u32>, // 身高(厘米)
|
||||
pub weight: Option<u32>, // 体重(公斤)
|
||||
pub measurements: Option<Measurements>, // 三围
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>, // 标签,如"性感"、"清纯"、"成熟"等
|
||||
pub avatar_path: Option<String>, // 头像照片路径
|
||||
pub photos: Vec<ModelPhoto>, // 模特照片集
|
||||
pub contact_info: Option<ContactInfo>, // 联系信息
|
||||
pub social_media: Option<SocialMedia>, // 社交媒体信息
|
||||
pub status: ModelStatus,
|
||||
pub rating: Option<f32>, // 评分 (1.0-5.0)
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// 性别枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Gender {
|
||||
Male,
|
||||
Female,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 三围信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Measurements {
|
||||
pub bust: u32, // 胸围
|
||||
pub waist: u32, // 腰围
|
||||
pub hips: u32, // 臀围
|
||||
}
|
||||
|
||||
/// 模特照片
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelPhoto {
|
||||
pub id: String,
|
||||
pub model_id: String,
|
||||
pub file_path: String,
|
||||
pub file_name: String,
|
||||
pub file_size: u64,
|
||||
pub photo_type: PhotoType,
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_cover: bool, // 是否为封面照片
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 照片类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PhotoType {
|
||||
Portrait, // 肖像照
|
||||
FullBody, // 全身照
|
||||
Headshot, // 头像照
|
||||
Artistic, // 艺术照
|
||||
Commercial, // 商业照
|
||||
Casual, // 生活照
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 联系信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContactInfo {
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub wechat: Option<String>,
|
||||
pub qq: Option<String>,
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
/// 社交媒体信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SocialMedia {
|
||||
pub weibo: Option<String>,
|
||||
pub instagram: Option<String>,
|
||||
pub tiktok: Option<String>,
|
||||
pub xiaohongshu: Option<String>, // 小红书
|
||||
pub douyin: Option<String>, // 抖音
|
||||
}
|
||||
|
||||
/// 模特状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ModelStatus {
|
||||
Active, // 活跃
|
||||
Inactive, // 不活跃
|
||||
Retired, // 退役
|
||||
Suspended, // 暂停
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// 创建新的模特实例
|
||||
pub fn new(
|
||||
name: String,
|
||||
gender: Gender,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
stage_name: None,
|
||||
gender,
|
||||
age: None,
|
||||
height: None,
|
||||
weight: None,
|
||||
measurements: None,
|
||||
description: None,
|
||||
tags: Vec::new(),
|
||||
avatar_path: None,
|
||||
photos: Vec::new(),
|
||||
contact_info: None,
|
||||
social_media: None,
|
||||
status: ModelStatus::Active,
|
||||
rating: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新模特基本信息
|
||||
pub fn update_basic_info(
|
||||
&mut self,
|
||||
name: Option<String>,
|
||||
stage_name: Option<String>,
|
||||
age: Option<u32>,
|
||||
height: Option<u32>,
|
||||
weight: Option<u32>,
|
||||
description: Option<String>,
|
||||
) {
|
||||
if let Some(name) = name {
|
||||
self.name = name;
|
||||
}
|
||||
if stage_name.is_some() {
|
||||
self.stage_name = stage_name;
|
||||
}
|
||||
if age.is_some() {
|
||||
self.age = age;
|
||||
}
|
||||
if height.is_some() {
|
||||
self.height = height;
|
||||
}
|
||||
if weight.is_some() {
|
||||
self.weight = weight;
|
||||
}
|
||||
if description.is_some() {
|
||||
self.description = description;
|
||||
}
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 设置三围信息
|
||||
pub fn set_measurements(&mut self, measurements: Option<Measurements>) {
|
||||
self.measurements = measurements;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 添加标签
|
||||
pub fn add_tag(&mut self, tag: String) {
|
||||
if !self.tags.contains(&tag) {
|
||||
self.tags.push(tag);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除标签
|
||||
pub fn remove_tag(&mut self, tag: &str) {
|
||||
if let Some(pos) = self.tags.iter().position(|t| t == tag) {
|
||||
self.tags.remove(pos);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置头像
|
||||
pub fn set_avatar(&mut self, avatar_path: Option<String>) {
|
||||
self.avatar_path = avatar_path;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 添加照片
|
||||
pub fn add_photo(&mut self, photo: ModelPhoto) {
|
||||
self.photos.push(photo);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 移除照片
|
||||
pub fn remove_photo(&mut self, photo_id: &str) {
|
||||
if let Some(pos) = self.photos.iter().position(|p| p.id == photo_id) {
|
||||
self.photos.remove(pos);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置联系信息
|
||||
pub fn set_contact_info(&mut self, contact_info: Option<ContactInfo>) {
|
||||
self.contact_info = contact_info;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 设置社交媒体信息
|
||||
pub fn set_social_media(&mut self, social_media: Option<SocialMedia>) {
|
||||
self.social_media = social_media;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 更新状态
|
||||
pub fn update_status(&mut self, status: ModelStatus) {
|
||||
self.status = status;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 设置评分
|
||||
pub fn set_rating(&mut self, rating: Option<f32>) {
|
||||
if let Some(r) = rating {
|
||||
if r >= 1.0 && r <= 5.0 {
|
||||
self.rating = Some(r);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
} else {
|
||||
self.rating = None;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证模特数据
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("模特姓名不能为空".to_string());
|
||||
}
|
||||
|
||||
if self.name.len() > 50 {
|
||||
return Err("模特姓名不能超过50个字符".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref stage_name) = self.stage_name {
|
||||
if stage_name.len() > 50 {
|
||||
return Err("艺名不能超过50个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(age) = self.age {
|
||||
if age > 100 {
|
||||
return Err("年龄不能超过100岁".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(height) = self.height {
|
||||
if height < 50 || height > 250 {
|
||||
return Err("身高必须在50-250厘米之间".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(weight) = self.weight {
|
||||
if weight < 20 || weight > 200 {
|
||||
return Err("体重必须在20-200公斤之间".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref description) = self.description {
|
||||
if description.len() > 1000 {
|
||||
return Err("描述不能超过1000个字符".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rating) = self.rating {
|
||||
if rating < 1.0 || rating > 5.0 {
|
||||
return Err("评分必须在1.0-5.0之间".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取封面照片
|
||||
pub fn get_cover_photo(&self) -> Option<&ModelPhoto> {
|
||||
self.photos.iter().find(|p| p.is_cover)
|
||||
}
|
||||
|
||||
/// 获取显示名称(优先使用艺名)
|
||||
pub fn get_display_name(&self) -> &str {
|
||||
self.stage_name.as_ref().unwrap_or(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPhoto {
|
||||
/// 创建新的模特照片
|
||||
pub fn new(
|
||||
model_id: String,
|
||||
file_path: String,
|
||||
file_name: String,
|
||||
file_size: u64,
|
||||
photo_type: PhotoType,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
model_id,
|
||||
file_path,
|
||||
file_name,
|
||||
file_size,
|
||||
photo_type,
|
||||
description: None,
|
||||
tags: Vec::new(),
|
||||
is_cover: false,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置为封面照片
|
||||
pub fn set_as_cover(&mut self) {
|
||||
self.is_cover = true;
|
||||
}
|
||||
|
||||
/// 取消封面照片
|
||||
pub fn unset_as_cover(&mut self) {
|
||||
self.is_cover = false;
|
||||
}
|
||||
|
||||
/// 添加标签
|
||||
pub fn add_tag(&mut self, tag: String) {
|
||||
if !self.tags.contains(&tag) {
|
||||
self.tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除标签
|
||||
pub fn remove_tag(&mut self, tag: &str) {
|
||||
if let Some(pos) = self.tags.iter().position(|t| t == tag) {
|
||||
self.tags.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建模特请求模型
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateModelRequest {
|
||||
pub name: String,
|
||||
pub stage_name: Option<String>,
|
||||
pub gender: Gender,
|
||||
pub age: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub weight: Option<u32>,
|
||||
pub measurements: Option<Measurements>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub contact_info: Option<ContactInfo>,
|
||||
pub social_media: Option<SocialMedia>,
|
||||
}
|
||||
|
||||
/// 更新模特请求模型
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateModelRequest {
|
||||
pub name: Option<String>,
|
||||
pub stage_name: Option<String>,
|
||||
pub age: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub weight: Option<u32>,
|
||||
pub measurements: Option<Measurements>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub contact_info: Option<ContactInfo>,
|
||||
pub social_media: Option<SocialMedia>,
|
||||
pub status: Option<ModelStatus>,
|
||||
pub rating: Option<f32>,
|
||||
}
|
||||
|
||||
/// 模特查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelQueryParams {
|
||||
pub name: Option<String>,
|
||||
pub gender: Option<Gender>,
|
||||
pub min_age: Option<u32>,
|
||||
pub max_age: Option<u32>,
|
||||
pub min_height: Option<u32>,
|
||||
pub max_height: Option<u32>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub status: Option<ModelStatus>,
|
||||
pub min_rating: Option<f32>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
@@ -34,13 +34,14 @@ impl MaterialRepository {
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO materials (
|
||||
id, project_id, name, original_path, file_size, md5_hash,
|
||||
id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
(
|
||||
&material.id,
|
||||
&material.project_id,
|
||||
&material.model_id,
|
||||
&material.name,
|
||||
&material.original_path,
|
||||
material.file_size as i64,
|
||||
@@ -64,7 +65,7 @@ impl MaterialRepository {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, original_path, file_size, md5_hash,
|
||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE id = ?1"
|
||||
@@ -86,7 +87,7 @@ impl MaterialRepository {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, original_path, file_size, md5_hash,
|
||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
@@ -127,12 +128,13 @@ impl MaterialRepository {
|
||||
.transpose()?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE materials SET
|
||||
name = ?1, processing_status = ?2, metadata = ?3, scene_detection = ?4,
|
||||
updated_at = ?5, processed_at = ?6, error_message = ?7
|
||||
WHERE id = ?8",
|
||||
"UPDATE materials SET
|
||||
name = ?1, model_id = ?2, processing_status = ?3, metadata = ?4, scene_detection = ?5,
|
||||
updated_at = ?6, processed_at = ?7, error_message = ?8
|
||||
WHERE id = ?9",
|
||||
(
|
||||
&material.name,
|
||||
&material.model_id,
|
||||
serde_json::to_string(&material.processing_status)?,
|
||||
metadata_json,
|
||||
scene_detection_json,
|
||||
@@ -301,6 +303,7 @@ impl MaterialRepository {
|
||||
Ok(Material {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
model_id: row.get("model_id")?,
|
||||
name: row.get("name")?,
|
||||
original_path: row.get("original_path")?,
|
||||
file_size: row.get::<_, i64>("file_size")? as u64,
|
||||
@@ -354,4 +357,97 @@ impl MaterialRepository {
|
||||
))?.with_timezone(&chrono::Utc),
|
||||
})
|
||||
}
|
||||
|
||||
/// 关联素材到模特
|
||||
pub fn associate_material_to_model(&self, material_id: &str, model_id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE materials SET model_id = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
(model_id, chrono::Utc::now().to_rfc3339(), material_id),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 取消素材与模特的关联
|
||||
pub fn disassociate_material_from_model(&self, material_id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE materials SET model_id = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
(chrono::Utc::now().to_rfc3339(), material_id),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据模特ID获取关联的素材
|
||||
pub fn get_materials_by_model_id(&self, model_id: &str) -> Result<Vec<Material>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE model_id = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let material_iter = stmt.query_map([model_id], |row| {
|
||||
self.row_to_material(row)
|
||||
})?;
|
||||
|
||||
let mut materials = Vec::new();
|
||||
for material in material_iter {
|
||||
materials.push(material?);
|
||||
}
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
|
||||
/// 获取未关联模特的素材
|
||||
pub fn get_unassociated_materials(&self, project_id: Option<&str>) -> Result<Vec<Material>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let (query, params): (String, Vec<&str>) = if let Some(pid) = project_id {
|
||||
(
|
||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE model_id IS NULL AND project_id = ?1 ORDER BY created_at DESC".to_string(),
|
||||
vec![pid]
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||
material_type, processing_status, metadata, scene_detection,
|
||||
created_at, updated_at, processed_at, error_message
|
||||
FROM materials WHERE model_id IS NULL ORDER BY created_at DESC".to_string(),
|
||||
vec![]
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let materials = if params.is_empty() {
|
||||
let material_iter = stmt.query_map([], |row| {
|
||||
self.row_to_material(row)
|
||||
})?;
|
||||
let mut materials = Vec::new();
|
||||
for material in material_iter {
|
||||
materials.push(material?);
|
||||
}
|
||||
materials
|
||||
} else {
|
||||
let material_iter = stmt.query_map([params[0]], |row| {
|
||||
self.row_to_material(row)
|
||||
})?;
|
||||
let mut materials = Vec::new();
|
||||
for material in material_iter {
|
||||
materials.push(material?);
|
||||
}
|
||||
materials
|
||||
};
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod project_repository;
|
||||
pub mod material_repository;
|
||||
pub mod model_repository;
|
||||
|
||||
561
apps/desktop/src-tauri/src/data/repositories/model_repository.rs
Normal file
561
apps/desktop/src-tauri/src/data/repositories/model_repository.rs
Normal file
@@ -0,0 +1,561 @@
|
||||
use rusqlite::{Connection, Result, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::data::models::model::{
|
||||
Model, ModelPhoto, Gender, ModelStatus, PhotoType,
|
||||
Measurements, ContactInfo, SocialMedia, ModelQueryParams
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 模特数据仓库
|
||||
/// 遵循 Tauri 开发规范的仓库模式设计
|
||||
pub struct ModelRepository {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl ModelRepository {
|
||||
/// 创建新的模特仓库实例
|
||||
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
|
||||
/// 创建模特
|
||||
pub fn create(&self, model: &Model) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let measurements_json = model.measurements.as_ref()
|
||||
.map(|m| serde_json::to_string(m))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let tags_json = serde_json::to_string(&model.tags)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let contact_info_json = model.contact_info.as_ref()
|
||||
.map(|c| serde_json::to_string(c))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let social_media_json = model.social_media.as_ref()
|
||||
.map(|s| serde_json::to_string(s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO models (
|
||||
id, name, stage_name, gender, age, height, weight, measurements,
|
||||
description, tags, avatar_path, contact_info, social_media,
|
||||
status, rating, created_at, updated_at, is_active
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
[
|
||||
&model.id as &dyn rusqlite::ToSql,
|
||||
&model.name,
|
||||
&model.stage_name,
|
||||
&serde_json::to_string(&model.gender).unwrap(),
|
||||
&model.age,
|
||||
&model.height,
|
||||
&model.weight,
|
||||
&measurements_json,
|
||||
&model.description,
|
||||
&tags_json,
|
||||
&model.avatar_path,
|
||||
&contact_info_json,
|
||||
&social_media_json,
|
||||
&serde_json::to_string(&model.status).unwrap(),
|
||||
&model.rating,
|
||||
&model.created_at.to_rfc3339(),
|
||||
&model.updated_at.to_rfc3339(),
|
||||
&model.is_active,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID获取模特
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<Model>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
|
||||
description, tags, avatar_path, contact_info, social_media,
|
||||
status, rating, created_at, updated_at, is_active
|
||||
FROM models WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let model_iter = stmt.query_map([id], |row| {
|
||||
self.row_to_model(row)
|
||||
})?;
|
||||
|
||||
for model in model_iter {
|
||||
let mut model = model?;
|
||||
// 加载照片信息
|
||||
model.photos = self.get_photos(&model.id)?;
|
||||
return Ok(Some(model));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 获取所有模特
|
||||
pub fn get_all(&self) -> Result<Vec<Model>> {
|
||||
println!("ModelRepository::get_all 开始执行");
|
||||
|
||||
// 首先获取所有模特的基本信息
|
||||
let mut models = {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
println!("获取数据库连接成功");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
|
||||
description, tags, avatar_path, contact_info, social_media,
|
||||
status, rating, created_at, updated_at, is_active
|
||||
FROM models WHERE is_active = 1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
println!("SQL 语句准备成功");
|
||||
|
||||
let model_iter = stmt.query_map([], |row| {
|
||||
self.row_to_model(row)
|
||||
})?;
|
||||
println!("查询执行成功,开始处理结果");
|
||||
|
||||
let mut models = Vec::new();
|
||||
for (index, model) in model_iter.enumerate() {
|
||||
println!("处理第 {} 个模特记录", index + 1);
|
||||
println!("开始解析模特数据...");
|
||||
let model = match model {
|
||||
Ok(m) => {
|
||||
println!("模特数据解析成功: {}", m.name);
|
||||
m
|
||||
},
|
||||
Err(e) => {
|
||||
println!("模特数据解析失败: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
models.push(model);
|
||||
println!("第 {} 个模特记录处理完成", index + 1);
|
||||
}
|
||||
models
|
||||
}; // 释放数据库连接锁
|
||||
|
||||
// 然后为每个模特加载照片信息(避免嵌套锁)
|
||||
for model in &mut models {
|
||||
println!("开始加载模特 {} 的照片信息", model.id);
|
||||
model.photos = match self.get_photos(&model.id) {
|
||||
Ok(photos) => {
|
||||
println!("照片信息加载成功,共 {} 张照片", photos.len());
|
||||
photos
|
||||
},
|
||||
Err(e) => {
|
||||
println!("照片信息加载失败: {},跳过照片加载", e);
|
||||
// 暂时跳过照片加载,避免阻塞整个流程
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
println!("ModelRepository::get_all 执行完成,返回 {} 个模特", models.len());
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 根据查询参数搜索模特
|
||||
pub fn search(&self, params: &ModelQueryParams) -> Result<Vec<Model>> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let mut query = String::from(
|
||||
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
|
||||
description, tags, avatar_path, contact_info, social_media,
|
||||
status, rating, created_at, updated_at, is_active
|
||||
FROM models WHERE is_active = 1"
|
||||
);
|
||||
|
||||
let mut query_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref name) = params.name {
|
||||
query.push_str(" AND (name LIKE ?1 OR stage_name LIKE ?1)");
|
||||
query_params.push(Box::new(format!("%{}%", name)));
|
||||
}
|
||||
|
||||
if let Some(ref gender) = params.gender {
|
||||
query.push_str(&format!(" AND gender = ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(serde_json::to_string(gender).unwrap()));
|
||||
}
|
||||
|
||||
if let Some(min_age) = params.min_age {
|
||||
query.push_str(&format!(" AND age >= ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(min_age));
|
||||
}
|
||||
|
||||
if let Some(max_age) = params.max_age {
|
||||
query.push_str(&format!(" AND age <= ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(max_age));
|
||||
}
|
||||
|
||||
if let Some(min_height) = params.min_height {
|
||||
query.push_str(&format!(" AND height >= ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(min_height));
|
||||
}
|
||||
|
||||
if let Some(max_height) = params.max_height {
|
||||
query.push_str(&format!(" AND height <= ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(max_height));
|
||||
}
|
||||
|
||||
if let Some(ref status) = params.status {
|
||||
query.push_str(&format!(" AND status = ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(serde_json::to_string(status).unwrap()));
|
||||
}
|
||||
|
||||
if let Some(min_rating) = params.min_rating {
|
||||
query.push_str(&format!(" AND rating >= ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(min_rating));
|
||||
}
|
||||
|
||||
// 处理标签搜索
|
||||
if let Some(ref tags) = params.tags {
|
||||
for tag in tags {
|
||||
query.push_str(&format!(" AND tags LIKE ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(format!("%\"{}\"", tag)));
|
||||
}
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
if let Some(limit) = params.limit {
|
||||
query.push_str(&format!(" LIMIT ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(limit));
|
||||
|
||||
if let Some(offset) = params.offset {
|
||||
query.push_str(&format!(" OFFSET ?{}", query_params.len() + 1));
|
||||
query_params.push(Box::new(offset));
|
||||
}
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> = query_params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let model_iter = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
self.row_to_model(row)
|
||||
})?;
|
||||
|
||||
let mut models = Vec::new();
|
||||
for model in model_iter {
|
||||
let mut model = model?;
|
||||
// 加载照片信息
|
||||
model.photos = self.get_photos(&model.id)?;
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 更新模特
|
||||
pub fn update(&self, model: &Model) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let measurements_json = model.measurements.as_ref()
|
||||
.map(|m| serde_json::to_string(m))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let tags_json = serde_json::to_string(&model.tags)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let contact_info_json = model.contact_info.as_ref()
|
||||
.map(|c| serde_json::to_string(c))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
let social_media_json = model.social_media.as_ref()
|
||||
.map(|s| serde_json::to_string(s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE models SET
|
||||
name = ?1, stage_name = ?2, gender = ?3, age = ?4, height = ?5, weight = ?6,
|
||||
measurements = ?7, description = ?8, tags = ?9, avatar_path = ?10,
|
||||
contact_info = ?11, social_media = ?12, status = ?13, rating = ?14,
|
||||
updated_at = ?15, is_active = ?16
|
||||
WHERE id = ?17",
|
||||
[
|
||||
&model.name as &dyn rusqlite::ToSql,
|
||||
&model.stage_name,
|
||||
&serde_json::to_string(&model.gender).unwrap(),
|
||||
&model.age,
|
||||
&model.height,
|
||||
&model.weight,
|
||||
&measurements_json,
|
||||
&model.description,
|
||||
&tags_json,
|
||||
&model.avatar_path,
|
||||
&contact_info_json,
|
||||
&social_media_json,
|
||||
&serde_json::to_string(&model.status).unwrap(),
|
||||
&model.rating,
|
||||
&model.updated_at.to_rfc3339(),
|
||||
&model.is_active,
|
||||
&model.id,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除模特(软删除)
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE models SET is_active = 0, updated_at = ?1 WHERE id = ?2",
|
||||
(Utc::now().to_rfc3339(), id),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 物理删除模特
|
||||
pub fn delete_permanently(&self, id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
// 先删除照片记录
|
||||
conn.execute("DELETE FROM model_photos WHERE model_id = ?1", [id])?;
|
||||
|
||||
// 删除模特记录
|
||||
conn.execute("DELETE FROM models WHERE id = ?1", [id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模特照片
|
||||
pub fn get_photos(&self, model_id: &str) -> Result<Vec<ModelPhoto>> {
|
||||
println!("get_photos 开始执行,model_id: {}", model_id);
|
||||
|
||||
let conn = self.connection.lock().unwrap();
|
||||
println!("get_photos 获取数据库连接成功");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, model_id, file_path, file_name, file_size, photo_type,
|
||||
description, tags, is_cover, created_at
|
||||
FROM model_photos WHERE model_id = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
println!("get_photos SQL 语句准备成功");
|
||||
|
||||
let photo_iter = stmt.query_map([model_id], |row| {
|
||||
println!("get_photos 开始解析照片行数据");
|
||||
let result = self.row_to_photo(row);
|
||||
println!("get_photos 照片行数据解析结果: {:?}", result.is_ok());
|
||||
result
|
||||
})?;
|
||||
println!("get_photos 查询执行成功");
|
||||
|
||||
let mut photos = Vec::new();
|
||||
for (index, photo) in photo_iter.enumerate() {
|
||||
println!("get_photos 处理第 {} 张照片", index + 1);
|
||||
photos.push(photo?);
|
||||
}
|
||||
|
||||
println!("get_photos 执行完成,返回 {} 张照片", photos.len());
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// 添加模特照片
|
||||
pub fn add_photo(&self, photo: &ModelPhoto) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
let tags_json = serde_json::to_string(&photo.tags)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO model_photos (
|
||||
id, model_id, file_path, file_name, file_size, photo_type,
|
||||
description, tags, is_cover, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
(
|
||||
&photo.id,
|
||||
&photo.model_id,
|
||||
&photo.file_path,
|
||||
&photo.file_name,
|
||||
photo.file_size,
|
||||
serde_json::to_string(&photo.photo_type).unwrap(),
|
||||
&photo.description,
|
||||
tags_json,
|
||||
photo.is_cover,
|
||||
photo.created_at.to_rfc3339(),
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除模特照片
|
||||
pub fn delete_photo(&self, photo_id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
conn.execute("DELETE FROM model_photos WHERE id = ?1", [photo_id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置封面照片
|
||||
pub fn set_cover_photo(&self, model_id: &str, photo_id: &str) -> Result<()> {
|
||||
let conn = self.connection.lock().unwrap();
|
||||
|
||||
// 先取消所有封面照片
|
||||
conn.execute(
|
||||
"UPDATE model_photos SET is_cover = 0 WHERE model_id = ?1",
|
||||
[model_id],
|
||||
)?;
|
||||
|
||||
// 设置新的封面照片
|
||||
conn.execute(
|
||||
"UPDATE model_photos SET is_cover = 1 WHERE id = ?1 AND model_id = ?2",
|
||||
(photo_id, model_id),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将数据库行转换为模特对象
|
||||
fn row_to_model(&self, row: &Row) -> Result<Model> {
|
||||
let gender_str: String = row.get("gender")?;
|
||||
let gender: Gender = serde_json::from_str(&gender_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let status_str: String = row.get("status")?;
|
||||
let status: ModelStatus = serde_json::from_str(&status_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let measurements: Option<Measurements> = row.get::<_, Option<String>>("measurements")?
|
||||
.map(|s| serde_json::from_str(&s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let tags_str: String = row.get("tags")?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let contact_info: Option<ContactInfo> = row.get::<_, Option<String>>("contact_info")?
|
||||
.map(|s| serde_json::from_str(&s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let social_media: Option<SocialMedia> = row.get::<_, Option<String>>("social_media")?
|
||||
.map(|s| serde_json::from_str(&s))
|
||||
.transpose()
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let created_at_str: String = row.get("created_at")?;
|
||||
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let updated_at_str: String = row.get("updated_at")?;
|
||||
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
Ok(Model {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
stage_name: row.get("stage_name")?,
|
||||
gender,
|
||||
age: row.get("age")?,
|
||||
height: row.get("height")?,
|
||||
weight: row.get("weight")?,
|
||||
measurements,
|
||||
description: row.get("description")?,
|
||||
tags,
|
||||
avatar_path: row.get("avatar_path")?,
|
||||
photos: Vec::new(), // 将在调用方法中填充
|
||||
contact_info,
|
||||
social_media,
|
||||
status,
|
||||
rating: row.get("rating")?,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_active: row.get("is_active")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将数据库行转换为照片对象
|
||||
fn row_to_photo(&self, row: &Row) -> Result<ModelPhoto> {
|
||||
println!("row_to_photo 开始执行");
|
||||
|
||||
let photo_type_str: String = row.get("photo_type")?;
|
||||
println!("row_to_photo 获取 photo_type: {}", photo_type_str);
|
||||
|
||||
let photo_type: PhotoType = serde_json::from_str(&photo_type_str)
|
||||
.map_err(|e| {
|
||||
println!("row_to_photo photo_type 解析失败: {}", e);
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
)
|
||||
})?;
|
||||
println!("row_to_photo photo_type 解析成功");
|
||||
|
||||
let tags_str: String = row.get("tags")?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?;
|
||||
|
||||
let created_at_str: String = row.get("created_at")?;
|
||||
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
Ok(ModelPhoto {
|
||||
id: row.get("id")?,
|
||||
model_id: row.get("model_id")?,
|
||||
file_path: row.get("file_path")?,
|
||||
file_name: row.get("file_name")?,
|
||||
file_size: row.get("file_size")?,
|
||||
photo_type,
|
||||
description: row.get("description")?,
|
||||
tags,
|
||||
is_cover: row.get("is_cover")?,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,49 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建模特表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS models (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
stage_name TEXT,
|
||||
gender TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
height INTEGER,
|
||||
weight INTEGER,
|
||||
measurements TEXT,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
avatar_path TEXT,
|
||||
contact_info TEXT,
|
||||
social_media TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'Active',
|
||||
rating REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建模特照片表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
photo_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
is_cover BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (model_id) REFERENCES models (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建性能监控表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS performance_metrics (
|
||||
@@ -198,6 +241,48 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建模特表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_models_name ON models (name)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_models_gender ON models (gender)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_models_status ON models (status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_models_rating ON models (rating)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_models_created_at ON models (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建模特照片表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_photos_model_id ON model_photos (model_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_photos_type ON model_photos (photo_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_photos_cover ON model_photos (is_cover)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -261,6 +346,24 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加模特关联字段到素材表
|
||||
let has_model_id_column = conn.prepare("SELECT model_id FROM materials LIMIT 1").is_ok();
|
||||
if !has_model_id_column {
|
||||
println!("Adding model_id column to materials table");
|
||||
conn.execute(
|
||||
"ALTER TABLE materials ADD COLUMN model_id TEXT",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建模特关联索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_model_id ON materials (model_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
println!("Added model_id column and index to materials table");
|
||||
}
|
||||
|
||||
// 暂时禁用自动清理,避免启动时卡住
|
||||
// self.cleanup_invalid_projects()?;
|
||||
|
||||
|
||||
@@ -69,7 +69,30 @@ pub fn run() {
|
||||
commands::material_commands::generate_video_thumbnail,
|
||||
commands::material_commands::test_scene_detection,
|
||||
commands::material_commands::get_material_segments,
|
||||
commands::material_commands::test_video_split
|
||||
commands::material_commands::test_video_split,
|
||||
commands::material_commands::associate_material_to_model,
|
||||
commands::material_commands::disassociate_material_from_model,
|
||||
commands::material_commands::get_materials_by_model_id,
|
||||
commands::material_commands::get_unassociated_materials,
|
||||
// 模特管理命令
|
||||
commands::model_commands::create_model,
|
||||
commands::model_commands::get_model_by_id,
|
||||
commands::model_commands::get_all_models,
|
||||
commands::model_commands::search_models,
|
||||
commands::model_commands::update_model,
|
||||
commands::model_commands::delete_model,
|
||||
commands::model_commands::delete_model_permanently,
|
||||
commands::model_commands::add_model_photo,
|
||||
commands::model_commands::delete_model_photo,
|
||||
commands::model_commands::set_cover_photo,
|
||||
commands::model_commands::add_model_tag,
|
||||
commands::model_commands::remove_model_tag,
|
||||
commands::model_commands::update_model_status,
|
||||
commands::model_commands::set_model_rating,
|
||||
commands::model_commands::set_model_avatar,
|
||||
commands::model_commands::get_model_statistics,
|
||||
commands::model_commands::select_photo_files,
|
||||
commands::model_commands::select_photo_file
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -83,7 +83,7 @@ async fn import_materials_with_tauri_events(
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn, error, debug};
|
||||
use crate::data::models::material::Material;
|
||||
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
@@ -857,3 +857,68 @@ pub async fn test_video_split(
|
||||
Err(e) => Err(format!("切分失败: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 关联素材到模特命令
|
||||
#[command]
|
||||
pub async fn associate_material_to_model(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
repository.associate_material_to_model(&material_id, &model_id)
|
||||
.map_err(|e| format!("关联素材到模特失败: {}", e))
|
||||
}
|
||||
|
||||
/// 取消素材与模特的关联命令
|
||||
#[command]
|
||||
pub async fn disassociate_material_from_model(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
repository.disassociate_material_from_model(&material_id)
|
||||
.map_err(|e| format!("取消关联失败: {}", e))
|
||||
}
|
||||
|
||||
/// 根据模特ID获取关联的素材命令
|
||||
#[command]
|
||||
pub async fn get_materials_by_model_id(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<Vec<Material>, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
repository.get_materials_by_model_id(&model_id)
|
||||
.map_err(|e| format!("获取模特关联素材失败: {}", e))
|
||||
}
|
||||
|
||||
/// 获取未关联模特的素材命令
|
||||
#[command]
|
||||
pub async fn get_unassociated_materials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<Vec<Material>, String> {
|
||||
let repository_guard = state.get_material_repository()
|
||||
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("素材仓库未初始化")?;
|
||||
|
||||
repository.get_unassociated_materials(project_id.as_deref())
|
||||
.map_err(|e| format!("获取未关联素材失败: {}", e))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod project_commands;
|
||||
pub mod system_commands;
|
||||
pub mod material_commands;
|
||||
pub mod model_commands;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
use tauri::{command, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::model_service::{ModelService, ModelStatistics};
|
||||
use crate::data::models::model::{
|
||||
Model, ModelPhoto, CreateModelRequest, UpdateModelRequest,
|
||||
ModelQueryParams, PhotoType, ModelStatus
|
||||
};
|
||||
|
||||
/// 创建模特命令
|
||||
/// 遵循 Tauri 开发规范的命令设计模式
|
||||
#[command]
|
||||
pub async fn create_model(
|
||||
state: State<'_, AppState>,
|
||||
request: CreateModelRequest,
|
||||
) -> Result<Model, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::create_model(repository, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特详情命令
|
||||
#[command]
|
||||
pub async fn get_model_by_id(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<Model>, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::get_model_by_id(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取所有模特命令
|
||||
#[command]
|
||||
pub async fn get_all_models(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<Model>, String> {
|
||||
println!("开始获取所有模特...");
|
||||
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| {
|
||||
println!("获取模特仓库失败: {}", e);
|
||||
format!("获取模特仓库失败: {}", e)
|
||||
})?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or_else(|| {
|
||||
println!("模特仓库未初始化");
|
||||
"模特仓库未初始化".to_string()
|
||||
})?;
|
||||
|
||||
println!("调用 ModelService::get_all_models...");
|
||||
let result = ModelService::get_all_models(repository)
|
||||
.map_err(|e| {
|
||||
println!("ModelService::get_all_models 失败: {}", e);
|
||||
e.to_string()
|
||||
});
|
||||
|
||||
println!("get_all_models 结果: {:?}", result.is_ok());
|
||||
result
|
||||
}
|
||||
|
||||
/// 搜索模特命令
|
||||
#[command]
|
||||
pub async fn search_models(
|
||||
state: State<'_, AppState>,
|
||||
params: ModelQueryParams,
|
||||
) -> Result<Vec<Model>, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::search_models(repository, params)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新模特命令
|
||||
#[command]
|
||||
pub async fn update_model(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
request: UpdateModelRequest,
|
||||
) -> Result<Model, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::update_model(repository, &id, request)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除模特命令
|
||||
#[command]
|
||||
pub async fn delete_model(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::delete_model(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 永久删除模特命令
|
||||
#[command]
|
||||
pub async fn delete_model_permanently(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::delete_model_permanently(repository, &id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加模特照片命令
|
||||
#[command]
|
||||
pub async fn add_model_photo(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
file_path: String,
|
||||
photo_type: PhotoType,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<ModelPhoto, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::add_model_photo(repository, &model_id, file_path, photo_type, description, tags)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除模特照片命令
|
||||
#[command]
|
||||
pub async fn delete_model_photo(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
photo_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::delete_model_photo(repository, &model_id, &photo_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置封面照片命令
|
||||
#[command]
|
||||
pub async fn set_cover_photo(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
photo_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::set_cover_photo(repository, &model_id, &photo_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加标签命令
|
||||
#[command]
|
||||
pub async fn add_model_tag(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
tag: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::add_tag(repository, &model_id, tag)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 移除标签命令
|
||||
#[command]
|
||||
pub async fn remove_model_tag(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
tag: String,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::remove_tag(repository, &model_id, &tag)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新模特状态命令
|
||||
#[command]
|
||||
pub async fn update_model_status(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
status: ModelStatus,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::update_model_status(repository, &model_id, status)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置模特评分命令
|
||||
#[command]
|
||||
pub async fn set_model_rating(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
rating: Option<f32>,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::set_model_rating(repository, &model_id, rating)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置头像命令
|
||||
#[command]
|
||||
pub async fn set_model_avatar(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::set_avatar(repository, &model_id, avatar_path)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取模特统计信息命令
|
||||
#[command]
|
||||
pub async fn get_model_statistics(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ModelStatistics, String> {
|
||||
let repository_guard = state.get_model_repository()
|
||||
.map_err(|e| format!("获取模特仓库失败: {}", e))?;
|
||||
|
||||
let repository = repository_guard.as_ref()
|
||||
.ok_or("模特仓库未初始化")?;
|
||||
|
||||
ModelService::get_model_statistics(repository)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 选择照片文件命令
|
||||
#[command]
|
||||
pub async fn select_photo_files() -> Result<Vec<String>, String> {
|
||||
// TODO: 实现文件选择功能
|
||||
// 暂时返回空列表,等待 Tauri 文件对话框插件配置完成
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// 选择单个照片文件命令
|
||||
#[command]
|
||||
pub async fn select_photo_file() -> Result<Option<String>, String> {
|
||||
// TODO: 实现文件选择功能
|
||||
// 暂时返回 None,等待 Tauri 文件对话框插件配置完成
|
||||
Ok(None)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { ProjectList } from './components/ProjectList';
|
||||
import { ProjectForm } from './components/ProjectForm';
|
||||
import { ProjectDetails } from './pages/ProjectDetails';
|
||||
import Models from './pages/Models';
|
||||
import Navigation from './components/Navigation';
|
||||
import { useProjectStore } from './store/projectStore';
|
||||
import { useUIStore } from './store/uiStore';
|
||||
import { CreateProjectRequest, UpdateProjectRequest } from './types/project';
|
||||
@@ -49,11 +51,15 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||||
{/* 导航栏 */}
|
||||
<Navigation />
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8 max-w-7xl">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Play, FileText, AlertCircle } from 'lucide-react';
|
||||
import { Play, AlertCircle } from 'lucide-react';
|
||||
import { useMaterialStore } from '../store/materialStore';
|
||||
|
||||
/**
|
||||
@@ -15,21 +15,9 @@ export const FFmpegDebugPanel: React.FC = () => {
|
||||
|
||||
const [testFilePath, setTestFilePath] = useState('');
|
||||
const [testResult, setTestResult] = useState('');
|
||||
const [ffmpegStatus, setFFmpegStatus] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 获取FFmpeg状态
|
||||
const handleGetFFmpegStatus = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// const status = await getFFmpegStatus();
|
||||
setFFmpegStatus('FFmpeg状态检查功能暂时不可用');
|
||||
} catch (error) {
|
||||
setFFmpegStatus(`获取状态失败: ${error}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 选择测试文件
|
||||
const handleSelectTestFile = async () => {
|
||||
|
||||
326
apps/desktop/src/components/ModelCard.tsx
Normal file
326
apps/desktop/src/components/ModelCard.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React from 'react';
|
||||
import { Model, ModelStatus, Gender, ModelViewMode } from '../types/model';
|
||||
import {
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
StarIcon,
|
||||
UserIcon,
|
||||
TagIcon,
|
||||
CalendarIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
|
||||
interface ModelCardProps {
|
||||
model: Model;
|
||||
viewMode: ModelViewMode;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const ModelCard: React.FC<ModelCardProps> = ({
|
||||
model,
|
||||
viewMode,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSelect
|
||||
}) => {
|
||||
const getStatusColor = (status: ModelStatus) => {
|
||||
switch (status) {
|
||||
case ModelStatus.Active:
|
||||
return 'text-green-600 bg-green-100';
|
||||
case ModelStatus.Inactive:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
case ModelStatus.Retired:
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
case ModelStatus.Suspended:
|
||||
return 'text-red-600 bg-red-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: ModelStatus) => {
|
||||
switch (status) {
|
||||
case ModelStatus.Active:
|
||||
return '活跃';
|
||||
case ModelStatus.Inactive:
|
||||
return '不活跃';
|
||||
case ModelStatus.Retired:
|
||||
return '退役';
|
||||
case ModelStatus.Suspended:
|
||||
return '暂停';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getGenderText = (gender: Gender) => {
|
||||
switch (gender) {
|
||||
case Gender.Male:
|
||||
return '男';
|
||||
case Gender.Female:
|
||||
return '女';
|
||||
case Gender.Other:
|
||||
return '其他';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const renderRating = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 !== 0;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < fullStars) {
|
||||
stars.push(
|
||||
<StarIconSolid key={i} className="h-4 w-4 text-yellow-400" />
|
||||
);
|
||||
} else if (i === fullStars && hasHalfStar) {
|
||||
stars.push(
|
||||
<div key={i} className="relative">
|
||||
<StarIcon className="h-4 w-4 text-gray-300" />
|
||||
<div className="absolute inset-0 overflow-hidden w-1/2">
|
||||
<StarIconSolid className="h-4 w-4 text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
stars.push(
|
||||
<StarIcon key={i} className="h-4 w-4 text-gray-300" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{stars}
|
||||
<span className="text-sm text-gray-600 ml-1">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const coverPhoto = model.photos.find(photo => photo.is_cover);
|
||||
const avatarUrl = model.avatar_path || (coverPhoto ? `file://${coverPhoto.file_path}` : null);
|
||||
|
||||
if (viewMode === ModelViewMode.List) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 头像 */}
|
||||
<div className="flex-shrink-0">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={model.name}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<UserIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{model.stage_name || model.name}
|
||||
</h3>
|
||||
{model.stage_name && (
|
||||
<span className="text-sm text-gray-500">({model.name})</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(model.status)}`}>
|
||||
{getStatusText(model.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{getGenderText(model.gender)}</span>
|
||||
{model.age && <span>{model.age}岁</span>}
|
||||
{model.height && <span>{model.height}cm</span>}
|
||||
<span>{model.photos.length} 张照片</span>
|
||||
</div>
|
||||
|
||||
{model.rating && (
|
||||
<div className="mt-1">
|
||||
{renderRating(model.rating)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{model.tags.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{model.tags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{model.tags.length > 3 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
+{model.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onSelect && (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="编辑"
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="删除"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
{/* 封面图片 */}
|
||||
<div className="aspect-w-3 aspect-h-4 bg-gray-200">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={model.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-48 bg-gray-200 flex items-center justify-center">
|
||||
<UserIcon className="h-16 w-16 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(model.status)}`}>
|
||||
{getStatusText(model.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-4">
|
||||
{/* 标题和评分 */}
|
||||
<div className="mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{model.stage_name || model.name}
|
||||
</h3>
|
||||
{model.stage_name && (
|
||||
<p className="text-sm text-gray-500 truncate">{model.name}</p>
|
||||
)}
|
||||
{model.rating && (
|
||||
<div className="mt-1">
|
||||
{renderRating(model.rating)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-1 text-sm text-gray-600 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getGenderText(model.gender)}</span>
|
||||
{model.age && <span>• {model.age}岁</span>}
|
||||
</div>
|
||||
{model.height && (
|
||||
<div>{model.height}cm</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span>{model.photos.length} 张照片</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{model.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{model.tags.slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{model.tags.length > 2 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
+{model.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 mb-3">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>{new Date(model.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{onSelect && (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 mr-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="编辑"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="删除"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelCard;
|
||||
416
apps/desktop/src/components/ModelForm.tsx
Normal file
416
apps/desktop/src/components/ModelForm.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Model, Gender, CreateModelRequest, UpdateModelRequest, ModelFormErrors } from '../types/model';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface ModelFormProps {
|
||||
initialData?: Model;
|
||||
onSubmit: (data: CreateModelRequest | UpdateModelRequest) => void;
|
||||
onCancel: () => void;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
const ModelForm: React.FC<ModelFormProps> = ({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isEdit = false
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
stage_name: '',
|
||||
gender: Gender.Female,
|
||||
age: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
description: '',
|
||||
tags: [] as string[],
|
||||
measurements: {
|
||||
bust: '',
|
||||
waist: '',
|
||||
hips: ''
|
||||
},
|
||||
contact_info: {
|
||||
phone: '',
|
||||
email: '',
|
||||
wechat: '',
|
||||
qq: '',
|
||||
address: ''
|
||||
},
|
||||
social_media: {
|
||||
weibo: '',
|
||||
instagram: '',
|
||||
tiktok: '',
|
||||
xiaohongshu: '',
|
||||
douyin: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<ModelFormErrors>({});
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormData({
|
||||
name: initialData.name,
|
||||
stage_name: initialData.stage_name || '',
|
||||
gender: initialData.gender,
|
||||
age: initialData.age?.toString() || '',
|
||||
height: initialData.height?.toString() || '',
|
||||
weight: initialData.weight?.toString() || '',
|
||||
description: initialData.description || '',
|
||||
tags: [...initialData.tags],
|
||||
measurements: {
|
||||
bust: initialData.measurements?.bust?.toString() || '',
|
||||
waist: initialData.measurements?.waist?.toString() || '',
|
||||
hips: initialData.measurements?.hips?.toString() || ''
|
||||
},
|
||||
contact_info: {
|
||||
phone: initialData.contact_info?.phone || '',
|
||||
email: initialData.contact_info?.email || '',
|
||||
wechat: initialData.contact_info?.wechat || '',
|
||||
qq: initialData.contact_info?.qq || '',
|
||||
address: initialData.contact_info?.address || ''
|
||||
},
|
||||
social_media: {
|
||||
weibo: initialData.social_media?.weibo || '',
|
||||
instagram: initialData.social_media?.instagram || '',
|
||||
tiktok: initialData.social_media?.tiktok || '',
|
||||
xiaohongshu: initialData.social_media?.xiaohongshu || '',
|
||||
douyin: initialData.social_media?.douyin || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: ModelFormErrors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = '姓名不能为空';
|
||||
} else if (formData.name.length > 50) {
|
||||
newErrors.name = '姓名不能超过50个字符';
|
||||
}
|
||||
|
||||
if (formData.stage_name && formData.stage_name.length > 50) {
|
||||
newErrors.stage_name = '艺名不能超过50个字符';
|
||||
}
|
||||
|
||||
if (formData.age && (isNaN(Number(formData.age)) || Number(formData.age) < 0 || Number(formData.age) > 100)) {
|
||||
newErrors.age = '年龄必须是0-100之间的数字';
|
||||
}
|
||||
|
||||
if (formData.height && (isNaN(Number(formData.height)) || Number(formData.height) < 50 || Number(formData.height) > 250)) {
|
||||
newErrors.height = '身高必须是50-250厘米之间的数字';
|
||||
}
|
||||
|
||||
if (formData.weight && (isNaN(Number(formData.weight)) || Number(formData.weight) < 20 || Number(formData.weight) > 200)) {
|
||||
newErrors.weight = '体重必须是20-200公斤之间的数字';
|
||||
}
|
||||
|
||||
if (formData.description && formData.description.length > 1000) {
|
||||
newErrors.description = '描述不能超过1000个字符';
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
if (formData.contact_info.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.contact_info.email)) {
|
||||
newErrors.contact_info = { ...newErrors.contact_info, email: '邮箱格式不正确' };
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData: any = {
|
||||
name: formData.name.trim(),
|
||||
gender: formData.gender,
|
||||
};
|
||||
|
||||
if (formData.stage_name.trim()) {
|
||||
submitData.stage_name = formData.stage_name.trim();
|
||||
}
|
||||
|
||||
if (formData.age) {
|
||||
submitData.age = Number(formData.age);
|
||||
}
|
||||
|
||||
if (formData.height) {
|
||||
submitData.height = Number(formData.height);
|
||||
}
|
||||
|
||||
if (formData.weight) {
|
||||
submitData.weight = Number(formData.weight);
|
||||
}
|
||||
|
||||
if (formData.description.trim()) {
|
||||
submitData.description = formData.description.trim();
|
||||
}
|
||||
|
||||
if (formData.tags.length > 0) {
|
||||
submitData.tags = formData.tags;
|
||||
}
|
||||
|
||||
// 三围信息
|
||||
if (formData.measurements.bust || formData.measurements.waist || formData.measurements.hips) {
|
||||
submitData.measurements = {
|
||||
bust: Number(formData.measurements.bust) || 0,
|
||||
waist: Number(formData.measurements.waist) || 0,
|
||||
hips: Number(formData.measurements.hips) || 0
|
||||
};
|
||||
}
|
||||
|
||||
// 联系信息
|
||||
const hasContactInfo = Object.values(formData.contact_info).some(value => value.trim());
|
||||
if (hasContactInfo) {
|
||||
submitData.contact_info = Object.fromEntries(
|
||||
Object.entries(formData.contact_info).filter(([_, value]) => value.trim())
|
||||
);
|
||||
}
|
||||
|
||||
// 社交媒体信息
|
||||
const hasSocialMedia = Object.values(formData.social_media).some(value => value.trim());
|
||||
if (hasSocialMedia) {
|
||||
submitData.social_media = Object.fromEntries(
|
||||
Object.entries(formData.social_media).filter(([_, value]) => value.trim())
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags.includes(tag)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tag]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? '编辑模特' : '添加模特'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
姓名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
艺名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.stage_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, stage_name: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.stage_name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入艺名"
|
||||
/>
|
||||
{errors.stage_name && <p className="mt-1 text-sm text-red-600">{errors.stage_name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
性别 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.gender}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, gender: e.target.value as Gender }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={Gender.Female}>女</option>
|
||||
<option value={Gender.Male}>男</option>
|
||||
<option value={Gender.Other}>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
年龄
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, age: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.age ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入年龄"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
{errors.age && <p className="mt-1 text-sm text-red-600">{errors.age}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
身高 (cm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.height}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, height: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.height ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入身高"
|
||||
min="50"
|
||||
max="250"
|
||||
/>
|
||||
{errors.height && <p className="mt-1 text-sm text-red-600">{errors.height}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
体重 (kg)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.weight}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, weight: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.weight ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入体重"
|
||||
min="20"
|
||||
max="200"
|
||||
/>
|
||||
{errors.weight && <p className="mt-1 text-sm text-red-600">{errors.weight}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.description ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入描述信息"
|
||||
/>
|
||||
{errors.description && <p className="mt-1 text-sm text-red-600">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
标签
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={handleTagKeyPress}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="输入标签后按回车添加"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{isEdit ? '更新' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelForm;
|
||||
284
apps/desktop/src/components/ModelList.tsx
Normal file
284
apps/desktop/src/components/ModelList.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Model, ModelStatus, Gender, ModelViewMode, ModelSortBy, SortOrder } from '../types/model';
|
||||
import { modelService } from '../services/modelService';
|
||||
import ModelCard from './ModelCard';
|
||||
import ModelForm from './ModelForm';
|
||||
import ModelSearch from './ModelSearch';
|
||||
import { PlusIcon, Squares2X2Icon, ListBulletIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface ModelListProps {
|
||||
onModelSelect?: (model: Model) => void;
|
||||
}
|
||||
|
||||
const ModelList: React.FC<ModelListProps> = ({ onModelSelect }) => {
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ModelViewMode>(ModelViewMode.Grid);
|
||||
const [sortBy, setSortBy] = useState<ModelSortBy>(ModelSortBy.CreatedAt);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Desc);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ModelStatus | 'all'>('all');
|
||||
const [genderFilter, setGenderFilter] = useState<Gender | 'all'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterAndSortModels();
|
||||
}, [models, searchQuery, statusFilter, genderFilter, sortBy, sortOrder]);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('开始加载模特列表...');
|
||||
const modelList = await modelService.getAllModels();
|
||||
console.log('模特列表加载成功:', modelList);
|
||||
setModels(modelList);
|
||||
} catch (err) {
|
||||
console.error('加载模特列表失败:', err);
|
||||
setError(err instanceof Error ? err.message : '加载模特列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterAndSortModels = () => {
|
||||
let filtered = [...models];
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(model =>
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
(model.stage_name && model.stage_name.toLowerCase().includes(query)) ||
|
||||
model.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(model => model.status === statusFilter);
|
||||
}
|
||||
|
||||
// 性别过滤
|
||||
if (genderFilter !== 'all') {
|
||||
filtered = filtered.filter(model => model.gender === genderFilter);
|
||||
}
|
||||
|
||||
// 排序
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
|
||||
switch (sortBy) {
|
||||
case ModelSortBy.Name:
|
||||
aValue = a.name.toLowerCase();
|
||||
bValue = b.name.toLowerCase();
|
||||
break;
|
||||
case ModelSortBy.CreatedAt:
|
||||
aValue = new Date(a.created_at);
|
||||
bValue = new Date(b.created_at);
|
||||
break;
|
||||
case ModelSortBy.UpdatedAt:
|
||||
aValue = new Date(a.updated_at);
|
||||
bValue = new Date(b.updated_at);
|
||||
break;
|
||||
case ModelSortBy.Rating:
|
||||
aValue = a.rating || 0;
|
||||
bValue = b.rating || 0;
|
||||
break;
|
||||
case ModelSortBy.Age:
|
||||
aValue = a.age || 0;
|
||||
bValue = b.age || 0;
|
||||
break;
|
||||
case ModelSortBy.Height:
|
||||
aValue = a.height || 0;
|
||||
bValue = b.height || 0;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortOrder === SortOrder.Asc ? -1 : 1;
|
||||
if (aValue > bValue) return sortOrder === SortOrder.Asc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setFilteredModels(filtered);
|
||||
};
|
||||
|
||||
const handleCreateModel = async (modelData: any) => {
|
||||
try {
|
||||
await modelService.createModel(modelData);
|
||||
setShowCreateModal(false);
|
||||
await loadModels();
|
||||
} catch (err) {
|
||||
console.error('创建模特失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateModel = async (modelData: any) => {
|
||||
if (!editingModel) return;
|
||||
|
||||
try {
|
||||
await modelService.updateModel(editingModel.id, modelData);
|
||||
setEditingModel(null);
|
||||
await loadModels();
|
||||
} catch (err) {
|
||||
console.error('更新模特失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
if (!confirm('确定要删除这个模特吗?')) return;
|
||||
|
||||
try {
|
||||
await modelService.deleteModel(modelId);
|
||||
await loadModels();
|
||||
} catch (err) {
|
||||
console.error('删除模特失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-600 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={loadModels}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">模特管理</h1>
|
||||
<p className="text-gray-600">共 {filteredModels.length} 个模特</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 视图模式切换 */}
|
||||
<div className="flex rounded-lg border border-gray-300">
|
||||
<button
|
||||
onClick={() => setViewMode(ModelViewMode.Grid)}
|
||||
className={`p-2 ${viewMode === ModelViewMode.Grid
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(ModelViewMode.List)}
|
||||
className={`p-2 ${viewMode === ModelViewMode.List
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ListBulletIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 创建模特按钮 */}
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
添加模特
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<ModelSearch
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
genderFilter={genderFilter}
|
||||
onGenderFilterChange={setGenderFilter}
|
||||
sortBy={sortBy}
|
||||
onSortByChange={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortOrderChange={setSortOrder}
|
||||
/>
|
||||
|
||||
{/* 模特列表 */}
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 mb-4">
|
||||
{models.length === 0 ? '还没有添加任何模特' : '没有找到匹配的模特'}
|
||||
</div>
|
||||
{models.length === 0 && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
添加第一个模特
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === ModelViewMode.Grid
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
viewMode={viewMode}
|
||||
onEdit={() => setEditingModel(model)}
|
||||
onDelete={() => handleDeleteModel(model.id)}
|
||||
onSelect={() => onModelSelect?.(model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建模特模态框 */}
|
||||
{showCreateModal && (
|
||||
<ModelForm
|
||||
onSubmit={handleCreateModel}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑模特模态框 */}
|
||||
{editingModel && (
|
||||
<ModelForm
|
||||
initialData={editingModel}
|
||||
onSubmit={handleUpdateModel}
|
||||
onCancel={() => setEditingModel(null)}
|
||||
isEdit={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelList;
|
||||
222
apps/desktop/src/components/ModelSearch.tsx
Normal file
222
apps/desktop/src/components/ModelSearch.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { ModelStatus, Gender, ModelSortBy, SortOrder } from '../types/model';
|
||||
import { MagnifyingGlassIcon, FunnelIcon, ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface ModelSearchProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
statusFilter: ModelStatus | 'all';
|
||||
onStatusFilterChange: (status: ModelStatus | 'all') => void;
|
||||
genderFilter: Gender | 'all';
|
||||
onGenderFilterChange: (gender: Gender | 'all') => void;
|
||||
sortBy: ModelSortBy;
|
||||
onSortByChange: (sortBy: ModelSortBy) => void;
|
||||
sortOrder: SortOrder;
|
||||
onSortOrderChange: (order: SortOrder) => void;
|
||||
}
|
||||
|
||||
const ModelSearch: React.FC<ModelSearchProps> = ({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
genderFilter,
|
||||
onGenderFilterChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
sortOrder,
|
||||
onSortOrderChange
|
||||
}) => {
|
||||
const getStatusText = (status: ModelStatus | 'all') => {
|
||||
switch (status) {
|
||||
case 'all':
|
||||
return '全部状态';
|
||||
case ModelStatus.Active:
|
||||
return '活跃';
|
||||
case ModelStatus.Inactive:
|
||||
return '不活跃';
|
||||
case ModelStatus.Retired:
|
||||
return '退役';
|
||||
case ModelStatus.Suspended:
|
||||
return '暂停';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getGenderText = (gender: Gender | 'all') => {
|
||||
switch (gender) {
|
||||
case 'all':
|
||||
return '全部性别';
|
||||
case Gender.Male:
|
||||
return '男';
|
||||
case Gender.Female:
|
||||
return '女';
|
||||
case Gender.Other:
|
||||
return '其他';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getSortByText = (sortBy: ModelSortBy) => {
|
||||
switch (sortBy) {
|
||||
case ModelSortBy.Name:
|
||||
return '姓名';
|
||||
case ModelSortBy.CreatedAt:
|
||||
return '创建时间';
|
||||
case ModelSortBy.UpdatedAt:
|
||||
return '更新时间';
|
||||
case ModelSortBy.Rating:
|
||||
return '评分';
|
||||
case ModelSortBy.Age:
|
||||
return '年龄';
|
||||
case ModelSortBy.Height:
|
||||
return '身高';
|
||||
default:
|
||||
return '创建时间';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模特姓名、艺名或标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* 状态过滤 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FunnelIcon className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusFilterChange(e.target.value as ModelStatus | 'all')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value={ModelStatus.Active}>活跃</option>
|
||||
<option value={ModelStatus.Inactive}>不活跃</option>
|
||||
<option value={ModelStatus.Retired}>退役</option>
|
||||
<option value={ModelStatus.Suspended}>暂停</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 性别过滤 */}
|
||||
<div>
|
||||
<select
|
||||
value={genderFilter}
|
||||
onChange={(e) => onGenderFilterChange(e.target.value as Gender | 'all')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部性别</option>
|
||||
<option value={Gender.Male}>男</option>
|
||||
<option value={Gender.Female}>女</option>
|
||||
<option value={Gender.Other}>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序选择 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortByChange(e.target.value as ModelSortBy)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={ModelSortBy.CreatedAt}>创建时间</option>
|
||||
<option value={ModelSortBy.UpdatedAt}>更新时间</option>
|
||||
<option value={ModelSortBy.Name}>姓名</option>
|
||||
<option value={ModelSortBy.Rating}>评分</option>
|
||||
<option value={ModelSortBy.Age}>年龄</option>
|
||||
<option value={ModelSortBy.Height}>身高</option>
|
||||
</select>
|
||||
|
||||
{/* 排序方向 */}
|
||||
<button
|
||||
onClick={() => onSortOrderChange(sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc)}
|
||||
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
title={sortOrder === SortOrder.Asc ? '升序' : '降序'}
|
||||
>
|
||||
{sortOrder === SortOrder.Asc ? (
|
||||
<ArrowUpIcon className="h-5 w-5 text-gray-600" />
|
||||
) : (
|
||||
<ArrowDownIcon className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 活动过滤器显示 */}
|
||||
{(searchQuery || statusFilter !== 'all' || genderFilter !== 'all') && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{searchQuery && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
|
||||
搜索: {searchQuery}
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full">
|
||||
状态: {getStatusText(statusFilter)}
|
||||
<button
|
||||
onClick={() => onStatusFilterChange('all')}
|
||||
className="ml-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{genderFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-purple-100 text-purple-800 text-sm rounded-full">
|
||||
性别: {getGenderText(genderFilter)}
|
||||
<button
|
||||
onClick={() => onGenderFilterChange('all')}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 清除所有过滤器 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
onStatusFilterChange('all');
|
||||
onGenderFilterChange('all');
|
||||
}}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-full hover:bg-gray-50"
|
||||
>
|
||||
清除所有
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排序信息显示 */}
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
按 {getSortByText(sortBy)} {sortOrder === SortOrder.Asc ? '升序' : '降序'} 排列
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSearch;
|
||||
154
apps/desktop/src/components/Navigation.tsx
Normal file
154
apps/desktop/src/components/Navigation.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
FolderIcon,
|
||||
UserGroupIcon,
|
||||
PhotoIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Navigation: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: '项目管理',
|
||||
href: '/',
|
||||
icon: FolderIcon,
|
||||
description: '管理项目和素材'
|
||||
},
|
||||
{
|
||||
name: '模特管理',
|
||||
href: '/models',
|
||||
icon: UserGroupIcon,
|
||||
description: '管理模特信息'
|
||||
},
|
||||
{
|
||||
name: '素材库',
|
||||
href: '/materials',
|
||||
icon: PhotoIcon,
|
||||
description: '浏览所有素材'
|
||||
},
|
||||
{
|
||||
name: '统计分析',
|
||||
href: '/analytics',
|
||||
icon: ChartBarIcon,
|
||||
description: '查看数据统计'
|
||||
},
|
||||
{
|
||||
name: '设置',
|
||||
href: '/settings',
|
||||
icon: CogIcon,
|
||||
description: '应用设置'
|
||||
}
|
||||
];
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return location.pathname === '/';
|
||||
}
|
||||
return location.pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<h1 className="text-xl font-bold text-gray-900">MixVideo</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`group flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
title={item.description}
|
||||
>
|
||||
<Icon className={`mr-2 h-5 w-5 ${
|
||||
active ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-600'
|
||||
}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span className="sr-only">打开主菜单</span>
|
||||
<svg
|
||||
className="block h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-gray-50">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`group flex items-center px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`mr-3 h-6 w-6 ${
|
||||
active ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-600'
|
||||
}`} />
|
||||
<div>
|
||||
<div>{item.name}</div>
|
||||
<div className="text-sm text-gray-500">{item.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
12
apps/desktop/src/pages/Models.tsx
Normal file
12
apps/desktop/src/pages/Models.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ModelList from '../components/ModelList';
|
||||
|
||||
const Models: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ModelList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Models;
|
||||
310
apps/desktop/src/services/modelService.ts
Normal file
310
apps/desktop/src/services/modelService.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Model,
|
||||
CreateModelRequest,
|
||||
UpdateModelRequest,
|
||||
ModelQueryParams,
|
||||
ModelStatistics,
|
||||
ModelPhoto,
|
||||
PhotoType,
|
||||
ModelStatus,
|
||||
ModelAPI
|
||||
} from '../types/model';
|
||||
|
||||
/**
|
||||
* 模特管理服务
|
||||
* 遵循 Tauri 开发规范的前端服务层设计
|
||||
*/
|
||||
export class ModelService implements ModelAPI {
|
||||
/**
|
||||
* 创建模特
|
||||
*/
|
||||
async createModel(request: CreateModelRequest): Promise<Model> {
|
||||
try {
|
||||
const model = await invoke<Model>('create_model', { request });
|
||||
return model;
|
||||
} catch (error) {
|
||||
console.error('创建模特失败:', error);
|
||||
throw new Error(`创建模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取模特详情
|
||||
*/
|
||||
async getModelById(id: string): Promise<Model | null> {
|
||||
try {
|
||||
const model = await invoke<Model | null>('get_model_by_id', { id });
|
||||
return model;
|
||||
} catch (error) {
|
||||
console.error('获取模特详情失败:', error);
|
||||
throw new Error(`获取模特详情失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模特
|
||||
*/
|
||||
async getAllModels(): Promise<Model[]> {
|
||||
try {
|
||||
console.log('调用 get_all_models 命令...');
|
||||
const models = await invoke<Model[]>('get_all_models');
|
||||
console.log('get_all_models 命令返回:', models);
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error('获取模特列表失败:', error);
|
||||
throw new Error(`获取模特列表失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索模特
|
||||
*/
|
||||
async searchModels(params: ModelQueryParams): Promise<Model[]> {
|
||||
try {
|
||||
const models = await invoke<Model[]>('search_models', { params });
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error('搜索模特失败:', error);
|
||||
throw new Error(`搜索模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模特信息
|
||||
*/
|
||||
async updateModel(id: string, request: UpdateModelRequest): Promise<Model> {
|
||||
try {
|
||||
const model = await invoke<Model>('update_model', { id, request });
|
||||
return model;
|
||||
} catch (error) {
|
||||
console.error('更新模特失败:', error);
|
||||
throw new Error(`更新模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模特(软删除)
|
||||
*/
|
||||
async deleteModel(id: string): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_model', { id });
|
||||
} catch (error) {
|
||||
console.error('删除模特失败:', error);
|
||||
throw new Error(`删除模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 永久删除模特
|
||||
*/
|
||||
async deleteModelPermanently(id: string): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_model_permanently', { id });
|
||||
} catch (error) {
|
||||
console.error('永久删除模特失败:', error);
|
||||
throw new Error(`永久删除模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加模特照片
|
||||
*/
|
||||
async addModelPhoto(
|
||||
modelId: string,
|
||||
filePath: string,
|
||||
photoType: PhotoType,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
): Promise<ModelPhoto> {
|
||||
try {
|
||||
const photo = await invoke<ModelPhoto>('add_model_photo', {
|
||||
modelId,
|
||||
filePath,
|
||||
photoType,
|
||||
description,
|
||||
tags
|
||||
});
|
||||
return photo;
|
||||
} catch (error) {
|
||||
console.error('添加模特照片失败:', error);
|
||||
throw new Error(`添加模特照片失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模特照片
|
||||
*/
|
||||
async deleteModelPhoto(modelId: string, photoId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_model_photo', { modelId, photoId });
|
||||
} catch (error) {
|
||||
console.error('删除模特照片失败:', error);
|
||||
throw new Error(`删除模特照片失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置封面照片
|
||||
*/
|
||||
async setCoverPhoto(modelId: string, photoId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('set_cover_photo', { modelId, photoId });
|
||||
} catch (error) {
|
||||
console.error('设置封面照片失败:', error);
|
||||
throw new Error(`设置封面照片失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加标签
|
||||
*/
|
||||
async addModelTag(modelId: string, tag: string): Promise<void> {
|
||||
try {
|
||||
await invoke('add_model_tag', { modelId, tag });
|
||||
} catch (error) {
|
||||
console.error('添加标签失败:', error);
|
||||
throw new Error(`添加标签失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除标签
|
||||
*/
|
||||
async removeModelTag(modelId: string, tag: string): Promise<void> {
|
||||
try {
|
||||
await invoke('remove_model_tag', { modelId, tag });
|
||||
} catch (error) {
|
||||
console.error('移除标签失败:', error);
|
||||
throw new Error(`移除标签失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模特状态
|
||||
*/
|
||||
async updateModelStatus(modelId: string, status: ModelStatus): Promise<void> {
|
||||
try {
|
||||
await invoke('update_model_status', { modelId, status });
|
||||
} catch (error) {
|
||||
console.error('更新模特状态失败:', error);
|
||||
throw new Error(`更新模特状态失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模特评分
|
||||
*/
|
||||
async setModelRating(modelId: string, rating?: number): Promise<void> {
|
||||
try {
|
||||
await invoke('set_model_rating', { modelId, rating });
|
||||
} catch (error) {
|
||||
console.error('设置模特评分失败:', error);
|
||||
throw new Error(`设置模特评分失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置头像
|
||||
*/
|
||||
async setModelAvatar(modelId: string, avatarPath?: string): Promise<void> {
|
||||
try {
|
||||
await invoke('set_model_avatar', { modelId, avatarPath });
|
||||
} catch (error) {
|
||||
console.error('设置头像失败:', error);
|
||||
throw new Error(`设置头像失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模特统计信息
|
||||
*/
|
||||
async getModelStatistics(): Promise<ModelStatistics> {
|
||||
try {
|
||||
const stats = await invoke<ModelStatistics>('get_model_statistics');
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('获取模特统计信息失败:', error);
|
||||
throw new Error(`获取模特统计信息失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择多个照片文件
|
||||
*/
|
||||
async selectPhotoFiles(): Promise<string[]> {
|
||||
try {
|
||||
const files = await invoke<string[]>('select_photo_files');
|
||||
return files;
|
||||
} catch (error) {
|
||||
console.error('选择照片文件失败:', error);
|
||||
throw new Error(`选择照片文件失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择单个照片文件
|
||||
*/
|
||||
async selectPhotoFile(): Promise<string | null> {
|
||||
try {
|
||||
const file = await invoke<string | null>('select_photo_file');
|
||||
return file;
|
||||
} catch (error) {
|
||||
console.error('选择照片文件失败:', error);
|
||||
throw new Error(`选择照片文件失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联素材到模特
|
||||
*/
|
||||
async associateMaterialToModel(materialId: string, modelId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('associate_material_to_model', { materialId, modelId });
|
||||
} catch (error) {
|
||||
console.error('关联素材到模特失败:', error);
|
||||
throw new Error(`关联素材到模特失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消素材与模特的关联
|
||||
*/
|
||||
async disassociateMaterialFromModel(materialId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('disassociate_material_from_model', { materialId });
|
||||
} catch (error) {
|
||||
console.error('取消关联失败:', error);
|
||||
throw new Error(`取消关联失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模特ID获取关联的素材
|
||||
*/
|
||||
async getMaterialsByModelId(modelId: string): Promise<any[]> {
|
||||
try {
|
||||
const materials = await invoke<any[]>('get_materials_by_model_id', { modelId });
|
||||
return materials;
|
||||
} catch (error) {
|
||||
console.error('获取模特关联素材失败:', error);
|
||||
throw new Error(`获取模特关联素材失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未关联模特的素材
|
||||
*/
|
||||
async getUnassociatedMaterials(projectId?: string): Promise<any[]> {
|
||||
try {
|
||||
const materials = await invoke<any[]>('get_unassociated_materials', { projectId });
|
||||
return materials;
|
||||
} catch (error) {
|
||||
console.error('获取未关联素材失败:', error);
|
||||
throw new Error(`获取未关联素材失败: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const modelService = new ModelService();
|
||||
265
apps/desktop/src/types/model.ts
Normal file
265
apps/desktop/src/types/model.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// 模特管理相关类型定义
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
stage_name?: string;
|
||||
gender: Gender;
|
||||
age?: number;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
measurements?: Measurements;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
avatar_path?: string;
|
||||
photos: ModelPhoto[];
|
||||
contact_info?: ContactInfo;
|
||||
social_media?: SocialMedia;
|
||||
status: ModelStatus;
|
||||
rating?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export enum Gender {
|
||||
Male = "Male",
|
||||
Female = "Female",
|
||||
Other = "Other"
|
||||
}
|
||||
|
||||
export interface Measurements {
|
||||
bust: number;
|
||||
waist: number;
|
||||
hips: number;
|
||||
}
|
||||
|
||||
export interface ModelPhoto {
|
||||
id: string;
|
||||
model_id: string;
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
photo_type: PhotoType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
is_cover: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export enum PhotoType {
|
||||
Portrait = "Portrait",
|
||||
FullBody = "FullBody",
|
||||
Headshot = "Headshot",
|
||||
Artistic = "Artistic",
|
||||
Commercial = "Commercial",
|
||||
Casual = "Casual",
|
||||
Other = "Other"
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
wechat?: string;
|
||||
qq?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface SocialMedia {
|
||||
weibo?: string;
|
||||
instagram?: string;
|
||||
tiktok?: string;
|
||||
xiaohongshu?: string;
|
||||
douyin?: string;
|
||||
}
|
||||
|
||||
export enum ModelStatus {
|
||||
Active = "Active",
|
||||
Inactive = "Inactive",
|
||||
Retired = "Retired",
|
||||
Suspended = "Suspended"
|
||||
}
|
||||
|
||||
export interface CreateModelRequest {
|
||||
name: string;
|
||||
stage_name?: string;
|
||||
gender: Gender;
|
||||
age?: number;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
measurements?: Measurements;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
contact_info?: ContactInfo;
|
||||
social_media?: SocialMedia;
|
||||
}
|
||||
|
||||
export interface UpdateModelRequest {
|
||||
name?: string;
|
||||
stage_name?: string;
|
||||
age?: number;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
measurements?: Measurements;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
contact_info?: ContactInfo;
|
||||
social_media?: SocialMedia;
|
||||
status?: ModelStatus;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface ModelQueryParams {
|
||||
name?: string;
|
||||
gender?: Gender;
|
||||
min_age?: number;
|
||||
max_age?: number;
|
||||
min_height?: number;
|
||||
max_height?: number;
|
||||
tags?: string[];
|
||||
status?: ModelStatus;
|
||||
min_rating?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ModelStatistics {
|
||||
total_count: number;
|
||||
active_count: number;
|
||||
inactive_count: number;
|
||||
retired_count: number;
|
||||
suspended_count: number;
|
||||
male_count: number;
|
||||
female_count: number;
|
||||
other_count: number;
|
||||
average_rating?: number;
|
||||
}
|
||||
|
||||
// 模特管理相关的API函数类型
|
||||
export interface ModelAPI {
|
||||
createModel: (request: CreateModelRequest) => Promise<Model>;
|
||||
getModelById: (id: string) => Promise<Model | null>;
|
||||
getAllModels: () => Promise<Model[]>;
|
||||
searchModels: (params: ModelQueryParams) => Promise<Model[]>;
|
||||
updateModel: (id: string, request: UpdateModelRequest) => Promise<Model>;
|
||||
deleteModel: (id: string) => Promise<void>;
|
||||
deleteModelPermanently: (id: string) => Promise<void>;
|
||||
addModelPhoto: (
|
||||
modelId: string,
|
||||
filePath: string,
|
||||
photoType: PhotoType,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
) => Promise<ModelPhoto>;
|
||||
deleteModelPhoto: (modelId: string, photoId: string) => Promise<void>;
|
||||
setCoverPhoto: (modelId: string, photoId: string) => Promise<void>;
|
||||
addModelTag: (modelId: string, tag: string) => Promise<void>;
|
||||
removeModelTag: (modelId: string, tag: string) => Promise<void>;
|
||||
updateModelStatus: (modelId: string, status: ModelStatus) => Promise<void>;
|
||||
setModelRating: (modelId: string, rating?: number) => Promise<void>;
|
||||
setModelAvatar: (modelId: string, avatarPath?: string) => Promise<void>;
|
||||
getModelStatistics: () => Promise<ModelStatistics>;
|
||||
selectPhotoFiles: () => Promise<string[]>;
|
||||
selectPhotoFile: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
// 表单验证相关
|
||||
export interface ModelFormErrors {
|
||||
name?: string;
|
||||
stage_name?: string;
|
||||
age?: string;
|
||||
height?: string;
|
||||
weight?: string;
|
||||
measurements?: {
|
||||
bust?: string;
|
||||
waist?: string;
|
||||
hips?: string;
|
||||
};
|
||||
description?: string;
|
||||
contact_info?: {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
wechat?: string;
|
||||
qq?: string;
|
||||
address?: string;
|
||||
};
|
||||
social_media?: {
|
||||
weibo?: string;
|
||||
instagram?: string;
|
||||
tiktok?: string;
|
||||
xiaohongshu?: string;
|
||||
douyin?: string;
|
||||
};
|
||||
rating?: string;
|
||||
}
|
||||
|
||||
// 模特卡片显示模式
|
||||
export enum ModelViewMode {
|
||||
Grid = "grid",
|
||||
List = "list",
|
||||
Card = "card"
|
||||
}
|
||||
|
||||
// 模特排序选项
|
||||
export enum ModelSortBy {
|
||||
Name = "name",
|
||||
CreatedAt = "created_at",
|
||||
UpdatedAt = "updated_at",
|
||||
Rating = "rating",
|
||||
Age = "age",
|
||||
Height = "height"
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc"
|
||||
}
|
||||
|
||||
export interface ModelSortOptions {
|
||||
sortBy: ModelSortBy;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
// 模特过滤选项
|
||||
export interface ModelFilterOptions {
|
||||
gender?: Gender[];
|
||||
status?: ModelStatus[];
|
||||
ageRange?: [number, number];
|
||||
heightRange?: [number, number];
|
||||
ratingRange?: [number, number];
|
||||
tags?: string[];
|
||||
hasPhotos?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
}
|
||||
|
||||
// 模特搜索结果
|
||||
export interface ModelSearchResult {
|
||||
models: Model[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// 模特照片上传进度
|
||||
export interface PhotoUploadProgress {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'completed' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 批量操作类型
|
||||
export enum ModelBatchAction {
|
||||
Delete = "delete",
|
||||
UpdateStatus = "update_status",
|
||||
AddTag = "add_tag",
|
||||
RemoveTag = "remove_tag",
|
||||
Export = "export"
|
||||
}
|
||||
|
||||
export interface ModelBatchOperation {
|
||||
action: ModelBatchAction;
|
||||
modelIds: string[];
|
||||
params?: any;
|
||||
}
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -32,8 +32,11 @@ importers:
|
||||
|
||||
apps/desktop:
|
||||
dependencies:
|
||||
'@heroicons/react':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(react@18.3.1)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2
|
||||
@@ -847,6 +850,14 @@ packages:
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dev: true
|
||||
|
||||
/@heroicons/react@2.2.0(react@18.3.1):
|
||||
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
|
||||
peerDependencies:
|
||||
react: '>= 16 || ^19.0.0-rc'
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@humanwhocodes/config-array@0.13.0:
|
||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
|
||||
Reference in New Issue
Block a user