feat: 实现模特管理功能

- 新增模特数据模型和数据库表结构
- 实现模特的完整CRUD操作
- 添加模特照片管理功能
- 实现素材与模特关联功能
- 创建模特管理前端界面
- 集成到主应用导航和路由
- 修复数据库连接死锁问题

功能特性:
- 模特基本信息管理(姓名、艺名、性别、年龄等)
- 照片管理和封面设置
- 标签系统
- 状态管理(活跃、不活跃、退役、暂停)
- 评分系统
- 搜索和过滤功能
- 素材关联功能
This commit is contained in:
imeepos
2025-07-14 01:39:14 +08:00
parent 38ef546c76
commit 5cf1f8bfca
28 changed files with 4131 additions and 44 deletions

View File

@@ -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
View File

@@ -2173,7 +2173,7 @@ dependencies = [
[[package]]
name = "mixvideo-desktop"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"anyhow",
"chrono",

View File

@@ -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"
}
}

View File

@@ -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 {

View File

@@ -1,3 +1,4 @@
pub mod project_service;
pub mod material_service;
pub mod model_service;
pub mod async_material_service;

View 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(&params)
.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>,
}

View File

@@ -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 {

View File

@@ -1,2 +1,3 @@
pub mod project;
pub mod material;
pub mod model;

View 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>,
}

View File

@@ -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)
}
}

View File

@@ -1,2 +1,3 @@
pub mod project_repository;
pub mod material_repository;
pub mod model_repository;

View 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,
})
}
}

View File

@@ -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()?;

View File

@@ -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| {
// 初始化日志系统

View File

@@ -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))
}

View File

@@ -1,3 +1,4 @@
pub mod project_commands;
pub mod system_commands;
pub mod material_commands;
pub mod model_commands;

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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 () => {

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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();

View 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
View File

@@ -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'}