feat: 实现服装搭配/高级筛选功能 - 自定义标签系统

新功能:
- 完整的自定义标签管理系统
- 支持标签分类和标签的CRUD操作
- 标签与实体(素材、模特、项目等)的关联管理
- 批量标签操作支持
- 标签使用统计功能

 数据库:
- 新增 custom_tag_categories 表(标签分类)
- 新增 custom_tags 表(自定义标签)
- 新增 tag_associations 表(标签关联)
- 支持默认标签数据初始化(暂时禁用)

 后端 (Rust):
- CustomTagRepository: 完整的数据访问层
- CustomTagCommands: Tauri命令接口
- 完善的错误处理和类型安全

 前端 (React + TypeScript):
- CustomTagSelector: 功能完整的标签选择器组件
- CustomTagService: API调用服务层
- 完整的TypeScript类型定义
- 集成到FilterPanel中的高级筛选功能

 技术特性:
- 遵循promptx/tauri-desktop-app-expert开发规范
- 使用连接池避免数据库死锁
- 响应式UI设计,支持实时创建标签
- 支持多维度筛选和搜索
- 完整的数据验证和错误处理

 注意事项:
- 默认标签初始化暂时禁用以避免启动阻塞
- 所有功能已编译通过并可正常使用
This commit is contained in:
imeepos
2025-07-18 10:40:26 +08:00
parent 9b43886a80
commit 025237f753
12 changed files with 2704 additions and 2 deletions

View File

@@ -0,0 +1,314 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// 自定义标签分类
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTagCategory {
/// 分类ID
pub id: String,
/// 分类名称
pub name: String,
/// 分类描述
pub description: Option<String>,
/// 分类颜色(十六进制)
pub color: String,
/// 分类图标
pub icon: Option<String>,
/// 排序顺序
pub sort_order: i32,
/// 是否激活
pub is_active: bool,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
impl Default for CustomTagCategory {
fn default() -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
name: String::new(),
description: None,
color: "#3b82f6".to_string(),
icon: None,
sort_order: 0,
is_active: true,
created_at: now,
updated_at: now,
}
}
}
/// 自定义标签
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTag {
/// 标签ID
pub id: String,
/// 所属分类ID
pub category_id: String,
/// 标签名称
pub name: String,
/// 标签描述
pub description: Option<String>,
/// 标签颜色(可选,继承分类颜色)
pub color: Option<String>,
/// 排序顺序
pub sort_order: i32,
/// 使用次数
pub usage_count: i32,
/// 是否激活
pub is_active: bool,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 更新时间
pub updated_at: DateTime<Utc>,
}
impl Default for CustomTag {
fn default() -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
category_id: String::new(),
name: String::new(),
description: None,
color: None,
sort_order: 0,
usage_count: 0,
is_active: true,
created_at: now,
updated_at: now,
}
}
}
/// 标签关联
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagAssociation {
/// 关联ID
pub id: String,
/// 标签ID
pub tag_id: String,
/// 实体类型material, model, project等
pub entity_type: String,
/// 实体ID
pub entity_id: String,
/// 创建时间
pub created_at: DateTime<Utc>,
}
impl Default for TagAssociation {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
tag_id: String::new(),
entity_type: String::new(),
entity_id: String::new(),
created_at: Utc::now(),
}
}
}
/// 带分类信息的标签
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTagWithCategory {
/// 标签信息
#[serde(flatten)]
pub tag: CustomTag,
/// 分类信息
pub category: CustomTagCategory,
}
/// 标签创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCustomTagRequest {
/// 分类ID
pub category_id: String,
/// 标签名称
pub name: String,
/// 标签描述
pub description: Option<String>,
/// 标签颜色
pub color: Option<String>,
}
/// 标签更新请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCustomTagRequest {
/// 标签名称
pub name: Option<String>,
/// 标签描述
pub description: Option<String>,
/// 标签颜色
pub color: Option<String>,
/// 排序顺序
pub sort_order: Option<i32>,
/// 是否激活
pub is_active: Option<bool>,
}
/// 标签分类创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCustomTagCategoryRequest {
/// 分类名称
pub name: String,
/// 分类描述
pub description: Option<String>,
/// 分类颜色
pub color: Option<String>,
/// 分类图标
pub icon: Option<String>,
}
/// 标签分类更新请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCustomTagCategoryRequest {
/// 分类名称
pub name: Option<String>,
/// 分类描述
pub description: Option<String>,
/// 分类颜色
pub color: Option<String>,
/// 分类图标
pub icon: Option<String>,
/// 排序顺序
pub sort_order: Option<i32>,
/// 是否激活
pub is_active: Option<bool>,
}
/// 标签筛选条件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagFilter {
/// 分类ID列表
pub category_ids: Option<Vec<String>>,
/// 标签名称搜索
pub name_search: Option<String>,
/// 是否只显示激活的标签
pub active_only: Option<bool>,
/// 实体类型过滤
pub entity_type: Option<String>,
/// 实体ID过滤
pub entity_id: Option<String>,
}
impl Default for TagFilter {
fn default() -> Self {
Self {
category_ids: None,
name_search: None,
active_only: Some(true),
entity_type: None,
entity_id: None,
}
}
}
/// 标签统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagStatistics {
/// 总标签数
pub total_tags: i32,
/// 激活标签数
pub active_tags: i32,
/// 总分类数
pub total_categories: i32,
/// 激活分类数
pub active_categories: i32,
/// 总关联数
pub total_associations: i32,
/// 按分类统计
pub by_category: Vec<CategoryTagCount>,
}
/// 分类标签数量统计
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryTagCount {
/// 分类信息
pub category: CustomTagCategory,
/// 标签数量
pub tag_count: i32,
/// 关联数量
pub association_count: i32,
}
/// 实体支持的标签类型
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EntityType {
/// 素材
Material,
/// 模特
Model,
/// 项目
Project,
/// 模板
Template,
/// 素材片段
MaterialSegment,
}
impl EntityType {
pub fn as_str(&self) -> &'static str {
match self {
EntityType::Material => "material",
EntityType::Model => "model",
EntityType::Project => "project",
EntityType::Template => "template",
EntityType::MaterialSegment => "material_segment",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"material" => Some(EntityType::Material),
"model" => Some(EntityType::Model),
"project" => Some(EntityType::Project),
"template" => Some(EntityType::Template),
"material_segment" => Some(EntityType::MaterialSegment),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_custom_tag_category_default() {
let category = CustomTagCategory::default();
assert!(!category.id.is_empty());
assert_eq!(category.color, "#3b82f6");
assert!(category.is_active);
assert_eq!(category.sort_order, 0);
}
#[test]
fn test_custom_tag_default() {
let tag = CustomTag::default();
assert!(!tag.id.is_empty());
assert!(tag.is_active);
assert_eq!(tag.usage_count, 0);
assert_eq!(tag.sort_order, 0);
}
#[test]
fn test_entity_type_conversion() {
assert_eq!(EntityType::Material.as_str(), "material");
assert_eq!(EntityType::Model.as_str(), "model");
assert!(matches!(EntityType::from_str("material"), Some(EntityType::Material)));
assert!(matches!(EntityType::from_str("model"), Some(EntityType::Model)));
assert!(EntityType::from_str("invalid").is_none());
}
#[test]
fn test_tag_filter_default() {
let filter = TagFilter::default();
assert!(filter.category_ids.is_none());
assert!(filter.name_search.is_none());
assert_eq!(filter.active_only, Some(true));
}
}

View File

@@ -12,3 +12,4 @@ pub mod export_record;
pub mod video_generation; pub mod video_generation;
pub mod outfit_search; pub mod outfit_search;
pub mod gemini_analysis; pub mod gemini_analysis;
pub mod custom_tag;

View File

@@ -0,0 +1,598 @@
use anyhow::Result;
use rusqlite::{params, Row};
use chrono::{DateTime, Utc};
use std::sync::Arc;
use crate::data::models::custom_tag::*;
use crate::infrastructure::database::Database;
/// 自定义标签仓库
/// 遵循 Tauri 开发规范的数据访问层设计
pub struct CustomTagRepository {
database: Arc<Database>,
}
impl CustomTagRepository {
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 创建标签分类
pub async fn create_category(&self, request: CreateCustomTagCategoryRequest) -> Result<CustomTagCategory> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let category = CustomTagCategory {
id: id.clone(),
name: request.name,
description: request.description,
color: request.color.unwrap_or_else(|| "#3b82f6".to_string()),
icon: request.icon,
sort_order: 0,
is_active: true,
created_at: now,
updated_at: now,
};
self.database.with_connection(|conn| {
// 获取最大排序值
let max_sort_order: i32 = conn.query_row(
"SELECT COALESCE(MAX(sort_order), 0) FROM custom_tag_categories",
[],
|row| row.get(0)
).unwrap_or(0);
conn.execute(
"INSERT INTO custom_tag_categories
(id, name, description, color, icon, sort_order, is_active, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
&category.id,
&category.name,
&category.description,
&category.color,
&category.icon,
max_sort_order + 1,
if category.is_active { 1 } else { 0 },
category.created_at.to_rfc3339(),
category.updated_at.to_rfc3339()
],
)?;
Ok(())
})?;
Ok(category)
}
/// 获取所有标签分类
pub async fn get_all_categories(&self, active_only: bool) -> Result<Vec<CustomTagCategory>> {
Ok(self.database.with_connection(|conn| {
let sql = if active_only {
"SELECT id, name, description, color, icon, sort_order, is_active, created_at, updated_at
FROM custom_tag_categories WHERE is_active = 1 ORDER BY sort_order, name"
} else {
"SELECT id, name, description, color, icon, sort_order, is_active, created_at, updated_at
FROM custom_tag_categories ORDER BY sort_order, name"
};
let mut stmt = conn.prepare(sql)?;
let category_iter = stmt.query_map([], |row| {
Self::row_to_category(row)
})?;
let mut categories = Vec::new();
for category in category_iter {
categories.push(category?);
}
Ok(categories)
})?)
}
/// 根据ID获取标签分类
pub async fn get_category_by_id(&self, id: &str) -> Result<Option<CustomTagCategory>> {
Ok(self.database.with_connection(|conn| {
let mut stmt = conn.prepare(
"SELECT id, name, description, color, icon, sort_order, is_active, created_at, updated_at
FROM custom_tag_categories WHERE id = ?1"
)?;
let result = stmt.query_row([id], |row| {
Self::row_to_category(row)
});
match result {
Ok(category) => Ok(Some(category)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
})?)
}
/// 更新标签分类
pub async fn update_category(&self, id: &str, request: UpdateCustomTagCategoryRequest) -> Result<Option<CustomTagCategory>> {
let now = Utc::now();
Ok(self.database.with_connection(|conn| {
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = &request.name {
updates.push("name = ?");
params.push(Box::new(name.clone()));
}
if let Some(description) = &request.description {
updates.push("description = ?");
params.push(Box::new(description.clone()));
}
if let Some(color) = &request.color {
updates.push("color = ?");
params.push(Box::new(color.clone()));
}
if let Some(icon) = &request.icon {
updates.push("icon = ?");
params.push(Box::new(icon.clone()));
}
if let Some(sort_order) = request.sort_order {
updates.push("sort_order = ?");
params.push(Box::new(sort_order));
}
if let Some(is_active) = request.is_active {
updates.push("is_active = ?");
params.push(Box::new(if is_active { 1 } else { 0 }));
}
if updates.is_empty() {
return Ok(None);
}
updates.push("updated_at = ?");
params.push(Box::new(now.to_rfc3339()));
params.push(Box::new(id.to_string()));
let sql = format!(
"UPDATE custom_tag_categories SET {} WHERE id = ?",
updates.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let affected = conn.execute(&sql, &params_refs[..])?;
if affected > 0 {
// 返回更新后的分类
let mut stmt = conn.prepare(
"SELECT id, name, description, color, icon, sort_order, is_active, created_at, updated_at
FROM custom_tag_categories WHERE id = ?1"
)?;
let category = stmt.query_row([id], |row| {
Ok(Self::row_to_category(row)?)
})?;
Ok(Some(category))
} else {
Ok(None)
}
})?)
}
/// 删除标签分类
pub async fn delete_category(&self, id: &str) -> Result<bool> {
Ok(self.database.with_connection(|conn| {
let affected = conn.execute(
"DELETE FROM custom_tag_categories WHERE id = ?1",
[id],
)?;
Ok(affected > 0)
})?)
}
/// 创建标签
pub async fn create_tag(&self, request: CreateCustomTagRequest) -> Result<CustomTag> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let tag = CustomTag {
id: id.clone(),
category_id: request.category_id,
name: request.name,
description: request.description,
color: request.color,
sort_order: 0,
usage_count: 0,
is_active: true,
created_at: now,
updated_at: now,
};
self.database.with_connection(|conn| {
// 获取该分类下的最大排序值
let max_sort_order: i32 = conn.query_row(
"SELECT COALESCE(MAX(sort_order), 0) FROM custom_tags WHERE category_id = ?1",
[&tag.category_id],
|row| row.get(0)
).unwrap_or(0);
conn.execute(
"INSERT INTO custom_tags
(id, category_id, name, description, color, sort_order, usage_count, is_active, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
&tag.id,
&tag.category_id,
&tag.name,
&tag.description,
&tag.color,
max_sort_order + 1,
tag.usage_count,
if tag.is_active { 1 } else { 0 },
tag.created_at.to_rfc3339(),
tag.updated_at.to_rfc3339()
],
)?;
Ok(())
})?;
Ok(tag)
}
/// 获取标签列表
pub async fn get_tags(&self, filter: TagFilter) -> Result<Vec<CustomTagWithCategory>> {
Ok(self.database.with_connection(|conn| {
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(category_ids) = &filter.category_ids {
if !category_ids.is_empty() {
let placeholders = category_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
where_clauses.push(format!("t.category_id IN ({})", placeholders));
for id in category_ids {
params.push(Box::new(id.clone()));
}
}
}
if let Some(name_search) = &filter.name_search {
where_clauses.push("t.name LIKE ?".to_string());
params.push(Box::new(format!("%{}%", name_search)));
}
if let Some(active_only) = filter.active_only {
if active_only {
where_clauses.push("t.is_active = 1".to_string());
where_clauses.push("c.is_active = 1".to_string());
}
}
let where_clause = if where_clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", where_clauses.join(" AND "))
};
let sql = format!(
"SELECT t.id, t.category_id, t.name, t.description, t.color, t.sort_order,
t.usage_count, t.is_active, t.created_at, t.updated_at,
c.id, c.name, c.description, c.color, c.icon, c.sort_order,
c.is_active, c.created_at, c.updated_at
FROM custom_tags t
JOIN custom_tag_categories c ON t.category_id = c.id
{} ORDER BY c.sort_order, t.sort_order, t.name",
where_clause
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let tag_iter = stmt.query_map(&params_refs[..], |row| {
let tag = Self::row_to_tag(row)?;
let category = Self::row_to_category_from_join(row, 10)?;
Ok(CustomTagWithCategory { tag, category })
})?;
let mut tags = Vec::new();
for tag in tag_iter {
tags.push(tag?);
}
Ok(tags)
})?)
}
/// 将数据库行转换为标签分类
fn row_to_category(row: &Row) -> rusqlite::Result<CustomTagCategory> {
let created_at_str: String = row.get(7)?;
let updated_at_str: String = row.get(8)?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(7, "created_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(8, "updated_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
Ok(CustomTagCategory {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
color: row.get(3)?,
icon: row.get(4)?,
sort_order: row.get(5)?,
is_active: row.get::<_, i32>(6)? != 0,
created_at,
updated_at,
})
}
/// 将数据库行转换为标签分类从JOIN查询
fn row_to_category_from_join(row: &Row, offset: usize) -> rusqlite::Result<CustomTagCategory> {
let created_at_str: String = row.get(offset + 7)?;
let updated_at_str: String = row.get(offset + 8)?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(offset + 7, "created_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(offset + 8, "updated_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
Ok(CustomTagCategory {
id: row.get(offset)?,
name: row.get(offset + 1)?,
description: row.get(offset + 2)?,
color: row.get(offset + 3)?,
icon: row.get(offset + 4)?,
sort_order: row.get(offset + 5)?,
is_active: row.get::<_, i32>(offset + 6)? != 0,
created_at,
updated_at,
})
}
/// 将数据库行转换为标签
fn row_to_tag(row: &Row) -> rusqlite::Result<CustomTag> {
let created_at_str: String = row.get(8)?;
let updated_at_str: String = row.get(9)?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(8, "created_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.map_err(|e| rusqlite::Error::InvalidColumnType(9, "updated_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
Ok(CustomTag {
id: row.get(0)?,
category_id: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
color: row.get(4)?,
sort_order: row.get(5)?,
usage_count: row.get(6)?,
is_active: row.get::<_, i32>(7)? != 0,
created_at,
updated_at,
})
}
/// 根据ID获取标签
pub async fn get_tag_by_id(&self, id: &str) -> Result<Option<CustomTag>> {
Ok(self.database.with_connection(|conn| {
let mut stmt = conn.prepare(
"SELECT id, category_id, name, description, color, sort_order, usage_count, is_active, created_at, updated_at
FROM custom_tags WHERE id = ?1"
)?;
let result = stmt.query_row([id], |row| {
Self::row_to_tag(row)
});
match result {
Ok(tag) => Ok(Some(tag)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
})?)
}
/// 更新标签
pub async fn update_tag(&self, id: &str, request: UpdateCustomTagRequest) -> Result<Option<CustomTag>> {
let now = Utc::now();
Ok(self.database.with_connection(|conn| {
let mut updates = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = &request.name {
updates.push("name = ?");
params.push(Box::new(name.clone()));
}
if let Some(description) = &request.description {
updates.push("description = ?");
params.push(Box::new(description.clone()));
}
if let Some(color) = &request.color {
updates.push("color = ?");
params.push(Box::new(color.clone()));
}
if let Some(sort_order) = request.sort_order {
updates.push("sort_order = ?");
params.push(Box::new(sort_order));
}
if let Some(is_active) = request.is_active {
updates.push("is_active = ?");
params.push(Box::new(if is_active { 1 } else { 0 }));
}
if updates.is_empty() {
return Ok(None);
}
updates.push("updated_at = ?");
params.push(Box::new(now.to_rfc3339()));
params.push(Box::new(id.to_string()));
let sql = format!(
"UPDATE custom_tags SET {} WHERE id = ?",
updates.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let affected = conn.execute(&sql, &params_refs[..])?;
if affected > 0 {
// 返回更新后的标签
let mut stmt = conn.prepare(
"SELECT id, category_id, name, description, color, sort_order, usage_count, is_active, created_at, updated_at
FROM custom_tags WHERE id = ?1"
)?;
let tag = stmt.query_row([id], |row| {
Self::row_to_tag(row)
})?;
Ok(Some(tag))
} else {
Ok(None)
}
})?)
}
/// 删除标签
pub async fn delete_tag(&self, id: &str) -> Result<bool> {
Ok(self.database.with_connection(|conn| {
let affected = conn.execute(
"DELETE FROM custom_tags WHERE id = ?1",
[id],
)?;
Ok(affected > 0)
})?)
}
/// 增加标签使用次数
pub async fn increment_tag_usage(&self, tag_id: &str) -> Result<()> {
Ok(self.database.with_connection(|conn| {
conn.execute(
"UPDATE custom_tags SET usage_count = usage_count + 1, updated_at = ?1 WHERE id = ?2",
[&Utc::now().to_rfc3339(), tag_id],
)?;
Ok(())
})?)
}
/// 创建标签关联
pub async fn create_tag_association(&self, tag_id: &str, entity_type: &str, entity_id: &str) -> Result<TagAssociation> {
let association = TagAssociation {
id: uuid::Uuid::new_v4().to_string(),
tag_id: tag_id.to_string(),
entity_type: entity_type.to_string(),
entity_id: entity_id.to_string(),
created_at: Utc::now(),
};
self.database.with_connection(|conn| {
conn.execute(
"INSERT OR IGNORE INTO tag_associations (id, tag_id, entity_type, entity_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
&association.id,
&association.tag_id,
&association.entity_type,
&association.entity_id,
association.created_at.to_rfc3339()
],
)?;
Ok(())
})?;
// 增加标签使用次数
self.increment_tag_usage(tag_id).await?;
Ok(association)
}
/// 删除标签关联
pub async fn delete_tag_association(&self, tag_id: &str, entity_type: &str, entity_id: &str) -> Result<bool> {
Ok(self.database.with_connection(|conn| {
let affected = conn.execute(
"DELETE FROM tag_associations WHERE tag_id = ?1 AND entity_type = ?2 AND entity_id = ?3",
[tag_id, entity_type, entity_id],
)?;
Ok(affected > 0)
})?)
}
/// 获取实体的标签
pub async fn get_entity_tags(&self, entity_type: &str, entity_id: &str) -> Result<Vec<CustomTagWithCategory>> {
Ok(self.database.with_connection(|conn| {
let sql = "SELECT t.id, t.category_id, t.name, t.description, t.color, t.sort_order,
t.usage_count, t.is_active, t.created_at, t.updated_at,
c.id, c.name, c.description, c.color, c.icon, c.sort_order,
c.is_active, c.created_at, c.updated_at
FROM custom_tags t
JOIN custom_tag_categories c ON t.category_id = c.id
JOIN tag_associations a ON t.id = a.tag_id
WHERE a.entity_type = ?1 AND a.entity_id = ?2 AND t.is_active = 1 AND c.is_active = 1
ORDER BY c.sort_order, t.sort_order, t.name";
let mut stmt = conn.prepare(sql)?;
let tag_iter = stmt.query_map([entity_type, entity_id], |row| {
let tag = Self::row_to_tag(row)?;
let category = Self::row_to_category_from_join(row, 10)?;
Ok(CustomTagWithCategory { tag, category })
})?;
let mut tags = Vec::new();
for tag in tag_iter {
tags.push(tag?);
}
Ok(tags)
})?)
}
/// 获取标签统计信息
pub async fn get_tag_statistics(&self) -> Result<TagStatistics> {
Ok(self.database.with_connection(|conn| {
// 获取总体统计
let total_tags: i32 = conn.query_row("SELECT COUNT(*) FROM custom_tags", [], |row| row.get(0))?;
let active_tags: i32 = conn.query_row("SELECT COUNT(*) FROM custom_tags WHERE is_active = 1", [], |row| row.get(0))?;
let total_categories: i32 = conn.query_row("SELECT COUNT(*) FROM custom_tag_categories", [], |row| row.get(0))?;
let active_categories: i32 = conn.query_row("SELECT COUNT(*) FROM custom_tag_categories WHERE is_active = 1", [], |row| row.get(0))?;
let total_associations: i32 = conn.query_row("SELECT COUNT(*) FROM tag_associations", [], |row| row.get(0))?;
// 获取按分类统计
let mut stmt = conn.prepare(
"SELECT c.id, c.name, c.description, c.color, c.icon, c.sort_order, c.is_active, c.created_at, c.updated_at,
COUNT(t.id) as tag_count,
COUNT(a.id) as association_count
FROM custom_tag_categories c
LEFT JOIN custom_tags t ON c.id = t.category_id AND t.is_active = 1
LEFT JOIN tag_associations a ON t.id = a.tag_id
WHERE c.is_active = 1
GROUP BY c.id
ORDER BY c.sort_order, c.name"
)?;
let category_iter = stmt.query_map([], |row| {
let category = Self::row_to_category(row)?;
let tag_count: i32 = row.get(9)?;
let association_count: i32 = row.get(10)?;
Ok(CategoryTagCount {
category,
tag_count,
association_count,
})
})?;
let mut by_category = Vec::new();
for category_count in category_iter {
by_category.push(category_count?);
}
Ok(TagStatistics {
total_tags,
active_tags,
total_categories,
active_categories,
total_associations,
by_category,
})
})?)
}
}

View File

@@ -8,3 +8,4 @@ pub mod project_template_binding_repository;
pub mod template_matching_result_repository; pub mod template_matching_result_repository;
pub mod export_record_repository; pub mod export_record_repository;
pub mod video_generation_repository; pub mod video_generation_repository;
pub mod custom_tag_repository;

View File

@@ -3,6 +3,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use uuid;
use crate::infrastructure::connection_pool::{ConnectionPool, ConnectionPoolConfig, PooledConnectionHandle}; use crate::infrastructure::connection_pool::{ConnectionPool, ConnectionPoolConfig, PooledConnectionHandle};
/// 统一的数据库连接句柄 /// 统一的数据库连接句柄
@@ -821,6 +822,55 @@ impl Database {
[], [],
)?; )?;
// 创建自定义标签分类表
conn.execute(
"CREATE TABLE IF NOT EXISTS custom_tag_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT DEFAULT '#3b82f6',
icon TEXT,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// 创建自定义标签表
conn.execute(
"CREATE TABLE IF NOT EXISTS custom_tags (
id TEXT PRIMARY KEY,
category_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
color TEXT,
sort_order INTEGER DEFAULT 0,
usage_count INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES custom_tag_categories (id) ON DELETE CASCADE,
UNIQUE(category_id, name)
)",
[],
)?;
// 创建标签关联表(用于关联标签到不同的实体)
conn.execute(
"CREATE TABLE IF NOT EXISTS tag_associations (
id TEXT PRIMARY KEY,
tag_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tag_id) REFERENCES custom_tags (id) ON DELETE CASCADE,
UNIQUE(tag_id, entity_type, entity_id)
)",
[],
)?;
// 创建索引 // 创建索引
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)", "CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
@@ -1346,6 +1396,127 @@ impl Database {
} }
} }
// 初始化默认标签分类(优化版本)
// self.initialize_default_tag_categories()?;
Ok(())
}
/// 初始化默认标签分类(使用连接池)
fn initialize_default_tag_categories(&self) -> Result<()> {
// 使用连接池而不是直接锁定连接
Ok(self.with_connection(|conn| {
// 检查是否已有标签分类
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM custom_tag_categories",
[],
|row| row.get(0)
)?;
if count == 0 {
println!("初始化默认标签分类...");
// 使用事务来提高性能和确保数据一致性
let tx = conn.unchecked_transaction()?;
// 插入默认标签分类
let default_categories = vec![
("服装类别", "服装的基本分类标签", "#3b82f6", "👕", 1),
("颜色风格", "颜色相关的风格标签", "#ef4444", "🎨", 2),
("设计风格", "设计和款式相关标签", "#8b5cf6", "", 3),
("场景环境", "适用场景和环境标签", "#10b981", "🌍", 4),
("材质质感", "材质和质感相关标签", "#f59e0b", "🧵", 5),
("品牌风格", "品牌和风格定位标签", "#6366f1", "🏷️", 6),
];
for (name, description, color, icon, sort_order) in default_categories {
let id = uuid::Uuid::new_v4().to_string();
tx.execute(
"INSERT INTO custom_tag_categories (id, name, description, color, icon, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
[&id, name, description, color, icon, &sort_order.to_string()],
)?;
// 为每个分类添加一些默认标签
self.initialize_default_tags_for_category_tx(&tx, &id, name)
.map_err(|e| rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ABORT),
Some(format!("Failed to initialize default tags: {}", e))
))?;
}
// 提交事务
tx.commit()?;
println!("默认标签分类初始化完成");
}
Ok(())
})?)
}
/// 为指定分类初始化默认标签(事务版本)
fn initialize_default_tags_for_category_tx(&self, tx: &rusqlite::Transaction, category_id: &str, category_name: &str) -> anyhow::Result<()> {
let default_tags = match category_name {
"服装类别" => vec![
("上装", "上半身服装"),
("下装", "下半身服装"),
("连衣裙", "连体裙装"),
("外套", "外层服装"),
("内衣", "贴身衣物"),
("配饰", "服装配件"),
],
"颜色风格" => vec![
("暖色调", "红、橙、黄等暖色系"),
("冷色调", "蓝、绿、紫等冷色系"),
("中性色", "黑、白、灰等中性色"),
("亮色系", "鲜艳明亮的颜色"),
("深色系", "深沉稳重的颜色"),
("渐变色", "多色渐变效果"),
],
"设计风格" => vec![
("简约", "简洁大方的设计"),
("复古", "怀旧复古风格"),
("时尚", "潮流时尚设计"),
("优雅", "优雅精致风格"),
("休闲", "轻松休闲风格"),
("正式", "正式商务风格"),
],
"场景环境" => vec![
("日常", "日常生活场景"),
("工作", "工作办公场景"),
("聚会", "社交聚会场景"),
("运动", "运动健身场景"),
("旅行", "旅游出行场景"),
("约会", "约会浪漫场景"),
],
"材质质感" => vec![
("棉质", "棉质材料"),
("丝质", "丝绸材质"),
("毛料", "毛织材料"),
("牛仔", "牛仔布料"),
("皮质", "皮革材质"),
("针织", "针织面料"),
],
"品牌风格" => vec![
("奢华", "奢华高端品牌"),
("轻奢", "轻奢时尚品牌"),
("快时尚", "快时尚品牌"),
("设计师", "设计师品牌"),
("运动", "运动品牌"),
("街头", "街头潮牌"),
],
_ => vec![],
};
for (i, (name, description)) in default_tags.iter().enumerate() {
let tag_id = uuid::Uuid::new_v4().to_string();
tx.execute(
"INSERT INTO custom_tags (id, category_id, name, description, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5)",
[&tag_id, category_id, name, description, &i.to_string()],
).map_err(|e| anyhow::anyhow!("Failed to insert tag: {}", e))?;
}
Ok(()) Ok(())
} }

View File

@@ -265,7 +265,22 @@ pub fn run() {
commands::outfit_search_commands::validate_outfit_image, commands::outfit_search_commands::validate_outfit_image,
commands::outfit_search_commands::get_supported_image_formats, commands::outfit_search_commands::get_supported_image_formats,
commands::outfit_search_commands::get_default_search_config, commands::outfit_search_commands::get_default_search_config,
commands::outfit_search_commands::get_outfit_search_config commands::outfit_search_commands::get_outfit_search_config,
// 自定义标签管理命令
commands::custom_tag_commands::get_custom_tag_categories,
commands::custom_tag_commands::create_custom_tag_category,
commands::custom_tag_commands::update_custom_tag_category,
commands::custom_tag_commands::delete_custom_tag_category,
commands::custom_tag_commands::get_custom_tags,
commands::custom_tag_commands::create_custom_tag,
commands::custom_tag_commands::update_custom_tag,
commands::custom_tag_commands::delete_custom_tag,
commands::custom_tag_commands::add_entity_tag,
commands::custom_tag_commands::remove_entity_tag,
commands::custom_tag_commands::get_entity_tags,
commands::custom_tag_commands::get_tag_statistics,
commands::custom_tag_commands::batch_add_entity_tags,
commands::custom_tag_commands::batch_remove_entity_tags
]) ])
.setup(|app| { .setup(|app| {
// 初始化日志系统 // 初始化日志系统

View File

@@ -0,0 +1,323 @@
use tauri::State;
use anyhow::Result;
use crate::app_state::AppState;
use crate::data::models::custom_tag::*;
use crate::data::repositories::custom_tag_repository::CustomTagRepository;
/// 获取所有标签分类
/// 遵循 Tauri 开发规范的命令设计原则
#[tauri::command]
pub async fn get_custom_tag_categories(
state: State<'_, AppState>,
active_only: Option<bool>,
) -> Result<Vec<CustomTagCategory>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.get_all_categories(active_only.unwrap_or(true))
.await
.map_err(|e| {
eprintln!("Failed to get custom tag categories: {}", e);
format!("获取标签分类失败: {}", e)
})
}
/// 创建标签分类
#[tauri::command]
pub async fn create_custom_tag_category(
state: State<'_, AppState>,
request: CreateCustomTagCategoryRequest,
) -> Result<CustomTagCategory, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.create_category(request)
.await
.map_err(|e| {
eprintln!("Failed to create custom tag category: {}", e);
format!("创建标签分类失败: {}", e)
})
}
/// 更新标签分类
#[tauri::command]
pub async fn update_custom_tag_category(
state: State<'_, AppState>,
id: String,
request: UpdateCustomTagCategoryRequest,
) -> Result<Option<CustomTagCategory>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.update_category(&id, request)
.await
.map_err(|e| {
eprintln!("Failed to update custom tag category: {}", e);
format!("更新标签分类失败: {}", e)
})
}
/// 删除标签分类
#[tauri::command]
pub async fn delete_custom_tag_category(
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.delete_category(&id)
.await
.map_err(|e| {
eprintln!("Failed to delete custom tag category: {}", e);
format!("删除标签分类失败: {}", e)
})
}
/// 获取标签列表
#[tauri::command]
pub async fn get_custom_tags(
state: State<'_, AppState>,
filter: Option<TagFilter>,
) -> Result<Vec<CustomTagWithCategory>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.get_tags(filter.unwrap_or_default())
.await
.map_err(|e| {
eprintln!("Failed to get custom tags: {}", e);
format!("获取标签列表失败: {}", e)
})
}
/// 创建标签
#[tauri::command]
pub async fn create_custom_tag(
state: State<'_, AppState>,
request: CreateCustomTagRequest,
) -> Result<CustomTag, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.create_tag(request)
.await
.map_err(|e| {
eprintln!("Failed to create custom tag: {}", e);
format!("创建标签失败: {}", e)
})
}
/// 更新标签
#[tauri::command]
pub async fn update_custom_tag(
state: State<'_, AppState>,
id: String,
request: UpdateCustomTagRequest,
) -> Result<Option<CustomTag>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.update_tag(&id, request)
.await
.map_err(|e| {
eprintln!("Failed to update custom tag: {}", e);
format!("更新标签失败: {}", e)
})
}
/// 删除标签
#[tauri::command]
pub async fn delete_custom_tag(
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.delete_tag(&id)
.await
.map_err(|e| {
eprintln!("Failed to delete custom tag: {}", e);
format!("删除标签失败: {}", e)
})
}
/// 为实体添加标签
#[tauri::command]
pub async fn add_entity_tag(
state: State<'_, AppState>,
tag_id: String,
entity_type: String,
entity_id: String,
) -> Result<TagAssociation, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.create_tag_association(&tag_id, &entity_type, &entity_id)
.await
.map_err(|e| {
eprintln!("Failed to add entity tag: {}", e);
format!("添加实体标签失败: {}", e)
})
}
/// 移除实体标签
#[tauri::command]
pub async fn remove_entity_tag(
state: State<'_, AppState>,
tag_id: String,
entity_type: String,
entity_id: String,
) -> Result<bool, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.delete_tag_association(&tag_id, &entity_type, &entity_id)
.await
.map_err(|e| {
eprintln!("Failed to remove entity tag: {}", e);
format!("移除实体标签失败: {}", e)
})
}
/// 获取实体的标签
#[tauri::command]
pub async fn get_entity_tags(
state: State<'_, AppState>,
entity_type: String,
entity_id: String,
) -> Result<Vec<CustomTagWithCategory>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.get_entity_tags(&entity_type, &entity_id)
.await
.map_err(|e| {
eprintln!("Failed to get entity tags: {}", e);
format!("获取实体标签失败: {}", e)
})
}
/// 获取标签统计信息
#[tauri::command]
pub async fn get_tag_statistics(
state: State<'_, AppState>,
) -> Result<TagStatistics, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
repository
.get_tag_statistics()
.await
.map_err(|e| {
eprintln!("Failed to get tag statistics: {}", e);
format!("获取标签统计失败: {}", e)
})
}
/// 批量为实体添加标签
#[tauri::command]
pub async fn batch_add_entity_tags(
state: State<'_, AppState>,
tag_ids: Vec<String>,
entity_type: String,
entity_id: String,
) -> Result<Vec<TagAssociation>, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
let mut associations = Vec::new();
for tag_id in tag_ids {
match repository.create_tag_association(&tag_id, &entity_type, &entity_id).await {
Ok(association) => associations.push(association),
Err(e) => {
eprintln!("Failed to add tag {} to entity {}: {}", tag_id, entity_id, e);
// 继续处理其他标签,不中断整个操作
}
}
}
Ok(associations)
}
/// 批量移除实体标签
#[tauri::command]
pub async fn batch_remove_entity_tags(
state: State<'_, AppState>,
tag_ids: Vec<String>,
entity_type: String,
entity_id: String,
) -> Result<i32, String> {
let database = {
let database_guard = state.database.lock().unwrap();
database_guard.as_ref().ok_or("Database not initialized")?.clone()
};
let repository = CustomTagRepository::new(database);
let mut removed_count = 0;
for tag_id in tag_ids {
match repository.delete_tag_association(&tag_id, &entity_type, &entity_id).await {
Ok(true) => removed_count += 1,
Ok(false) => {
// 标签关联不存在,继续处理
}
Err(e) => {
eprintln!("Failed to remove tag {} from entity {}: {}", tag_id, entity_id, e);
// 继续处理其他标签,不中断整个操作
}
}
}
Ok(removed_count)
}

View File

@@ -18,3 +18,4 @@ pub mod export_record_commands;
pub mod video_generation_commands; pub mod video_generation_commands;
pub mod tools_commands; pub mod tools_commands;
pub mod outfit_search_commands; pub mod outfit_search_commands;
pub mod custom_tag_commands;

View File

@@ -0,0 +1,618 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Search, Plus, X, ChevronDown } from 'lucide-react';
import {
CustomTagWithCategory,
CustomTagCategory,
TagSelectorProps,
CreateCustomTagRequest,
CreateCustomTagCategoryRequest,
} from '../types/customTag';
import { CustomTagService } from '../services/customTagService';
/**
* 自定义标签选择器组件
* 遵循 Tauri 开发规范和前端开发标准
*/
export const CustomTagSelector: React.FC<TagSelectorProps> = ({
selectedTagIds,
onSelectionChange,
multiple = true,
showCategoryFilter = true,
showSearch = true,
allowCreate = true,
placeholder = '选择标签...',
disabled = false,
}) => {
const [tags, setTags] = useState<CustomTagWithCategory[]>([]);
const [categories, setCategories] = useState<CustomTagCategory[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newCategoryName, setNewCategoryName] = useState('');
const [showCreateCategory, setShowCreateCategory] = useState(false);
// 加载数据
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [tagsData, categoriesData] = await Promise.all([
CustomTagService.getTags({ active_only: true }),
CustomTagService.getCategories(true),
]);
setTags(tagsData);
setCategories(categoriesData);
} catch (error) {
console.error('Failed to load tag data:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 过滤标签
const filteredTags = useMemo(() => {
let filtered = tags;
// 按分类过滤
if (selectedCategoryId) {
filtered = filtered.filter(tag => tag.category.id === selectedCategoryId);
}
// 按搜索查询过滤
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(tag =>
tag.tag.name.toLowerCase().includes(query) ||
tag.category.name.toLowerCase().includes(query) ||
(tag.tag.description && tag.tag.description.toLowerCase().includes(query))
);
}
return filtered;
}, [tags, selectedCategoryId, searchQuery]);
// 获取选中的标签
const selectedTags = useMemo(() => {
return tags.filter(tag => selectedTagIds.includes(tag.tag.id));
}, [tags, selectedTagIds]);
// 处理标签选择
const handleTagToggle = useCallback((tagId: string) => {
if (disabled) return;
let newSelectedIds: string[];
if (multiple) {
newSelectedIds = selectedTagIds.includes(tagId)
? selectedTagIds.filter(id => id !== tagId)
: [...selectedTagIds, tagId];
} else {
newSelectedIds = selectedTagIds.includes(tagId) ? [] : [tagId];
setIsDropdownOpen(false);
}
onSelectionChange(newSelectedIds);
}, [selectedTagIds, onSelectionChange, multiple, disabled]);
// 移除标签
const handleRemoveTag = useCallback((tagId: string, event: React.MouseEvent) => {
event.stopPropagation();
if (disabled) return;
const newSelectedIds = selectedTagIds.filter(id => id !== tagId);
onSelectionChange(newSelectedIds);
}, [selectedTagIds, onSelectionChange, disabled]);
// 创建新标签
const handleCreateTag = useCallback(async () => {
if (!newTagName.trim() || !selectedCategoryId) return;
setIsCreating(true);
try {
const request: CreateCustomTagRequest = {
category_id: selectedCategoryId,
name: newTagName.trim(),
};
const newTag = await CustomTagService.createTag(request);
// 重新加载数据
await loadData();
// 自动选择新创建的标签
if (multiple) {
onSelectionChange([...selectedTagIds, newTag.id]);
} else {
onSelectionChange([newTag.id]);
setIsDropdownOpen(false);
}
setNewTagName('');
} catch (error) {
console.error('Failed to create tag:', error);
} finally {
setIsCreating(false);
}
}, [newTagName, selectedCategoryId, selectedTagIds, onSelectionChange, multiple, loadData]);
// 创建新分类
const handleCreateCategory = useCallback(async () => {
if (!newCategoryName.trim()) return;
setIsCreating(true);
try {
const request: CreateCustomTagCategoryRequest = {
name: newCategoryName.trim(),
};
const newCategory = await CustomTagService.createCategory(request);
// 重新加载数据
await loadData();
// 自动选择新创建的分类
setSelectedCategoryId(newCategory.id);
setNewCategoryName('');
setShowCreateCategory(false);
} catch (error) {
console.error('Failed to create category:', error);
} finally {
setIsCreating(false);
}
}, [newCategoryName, loadData]);
return (
<div className="custom-tag-selector">
{/* 选中的标签显示 */}
<div
className={`tag-selector-input ${disabled ? 'disabled' : ''} ${isDropdownOpen ? 'open' : ''}`}
onClick={() => !disabled && setIsDropdownOpen(!isDropdownOpen)}
>
<div className="selected-tags">
{selectedTags.length > 0 ? (
selectedTags.map(tag => (
<span
key={tag.tag.id}
className="selected-tag"
style={{ backgroundColor: CustomTagService.getTagColor(tag) }}
>
{tag.tag.name}
{!disabled && (
<button
className="remove-tag-btn"
onClick={(e) => handleRemoveTag(tag.tag.id, e)}
type="button"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))
) : (
<span className="placeholder">{placeholder}</span>
)}
</div>
{!disabled && (
<ChevronDown className={`dropdown-icon ${isDropdownOpen ? 'open' : ''}`} />
)}
</div>
{/* 下拉选择面板 */}
{isDropdownOpen && !disabled && (
<div className="tag-dropdown">
{/* 搜索和过滤 */}
<div className="dropdown-header">
{showSearch && (
<div className="search-box">
<Search className="w-4 h-4 search-icon" />
<input
type="text"
placeholder="搜索标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
</div>
)}
{showCategoryFilter && (
<div className="category-filter">
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="category-select"
>
<option value=""></option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.icon} {category.name}
</option>
))}
</select>
{allowCreate && (
<button
className="create-category-btn"
onClick={() => setShowCreateCategory(true)}
type="button"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
{/* 标签列表 */}
<div className="tag-list">
{isLoading ? (
<div className="loading">...</div>
) : filteredTags.length > 0 ? (
filteredTags.map(tag => (
<div
key={tag.tag.id}
className={`tag-item ${selectedTagIds.includes(tag.tag.id) ? 'selected' : ''}`}
onClick={() => handleTagToggle(tag.tag.id)}
>
<div className="tag-content">
<span
className="tag-color"
style={{ backgroundColor: CustomTagService.getTagColor(tag) }}
/>
<span className="tag-name">{tag.tag.name}</span>
<span className="tag-category">{tag.category.name}</span>
</div>
<span className="tag-usage">{tag.tag.usage_count}</span>
</div>
))
) : (
<div className="no-tags">
{searchQuery ? '未找到匹配的标签' : '暂无标签'}
</div>
)}
</div>
{/* 创建新标签 */}
{allowCreate && selectedCategoryId && (
<div className="create-tag-section">
<div className="create-tag-input">
<input
type="text"
placeholder="输入新标签名称..."
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateTag()}
className="new-tag-input"
/>
<button
className="create-tag-btn"
onClick={handleCreateTag}
disabled={!newTagName.trim() || isCreating}
type="button"
>
{isCreating ? '创建中...' : '创建'}
</button>
</div>
</div>
)}
{/* 创建新分类 */}
{showCreateCategory && (
<div className="create-category-section">
<div className="create-category-input">
<input
type="text"
placeholder="输入新分类名称..."
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateCategory()}
className="new-category-input"
/>
<button
className="create-category-btn"
onClick={handleCreateCategory}
disabled={!newCategoryName.trim() || isCreating}
type="button"
>
{isCreating ? '创建中...' : '创建'}
</button>
<button
className="cancel-btn"
onClick={() => setShowCreateCategory(false)}
type="button"
>
</button>
</div>
</div>
)}
</div>
)}
<style>{`
.custom-tag-selector {
position: relative;
width: 100%;
}
.tag-selector-input {
min-height: 40px;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s ease;
}
.tag-selector-input:hover {
border-color: #cbd5e1;
}
.tag-selector-input.open {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.tag-selector-input.disabled {
background: #f8fafc;
cursor: not-allowed;
opacity: 0.6;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: #3b82f6;
color: white;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.remove-tag-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.remove-tag-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.placeholder {
color: #9ca3af;
font-size: 14px;
}
.dropdown-icon {
width: 16px;
height: 16px;
color: #6b7280;
transition: transform 0.2s ease;
}
.dropdown-icon.open {
transform: rotate(180deg);
}
.tag-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
margin-top: 4px;
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.dropdown-header {
padding: 12px;
border-bottom: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 8px;
}
.search-box {
position: relative;
}
.search-icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.search-input {
width: 100%;
padding: 6px 8px 6px 32px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 14px;
}
.category-filter {
display: flex;
gap: 8px;
align-items: center;
}
.category-select {
flex: 1;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 14px;
}
.create-category-btn {
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.create-category-btn:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.tag-list {
flex: 1;
overflow-y: auto;
max-height: 200px;
}
.tag-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s ease;
}
.tag-item:hover {
background: #f8fafc;
}
.tag-item.selected {
background: #eff6ff;
color: #1d4ed8;
}
.tag-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tag-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.tag-name {
font-weight: 500;
font-size: 14px;
}
.tag-category {
font-size: 12px;
color: #6b7280;
}
.tag-usage {
font-size: 12px;
color: #9ca3af;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 8px;
}
.loading, .no-tags {
padding: 16px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.create-tag-section, .create-category-section {
padding: 12px;
border-top: 1px solid #f1f5f9;
}
.create-tag-input, .create-category-input {
display: flex;
gap: 8px;
align-items: center;
}
.new-tag-input, .new-category-input {
flex: 1;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 14px;
}
.create-tag-btn, .create-category-btn {
padding: 6px 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.create-tag-btn:hover, .create-category-btn:hover {
background: #2563eb;
}
.create-tag-btn:disabled, .create-category-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-btn {
padding: 6px 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.cancel-btn:hover {
background: #4b5563;
}
`}</style>
</div>
);
};
export default CustomTagSelector;

View File

@@ -11,8 +11,9 @@ import {
} from '../../types/outfitSearch'; } from '../../types/outfitSearch';
import { ColorPicker } from './ColorPicker'; import { ColorPicker } from './ColorPicker';
import { ColorUtils } from '../../utils/colorUtils'; import { ColorUtils } from '../../utils/colorUtils';
import { Upload, Image as ImageIcon, X, Sparkles, AlertCircle } from 'lucide-react'; import { Upload, Image as ImageIcon, X, Sparkles, AlertCircle, Tags } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { CustomTagSelector } from '../CustomTagSelector';
/** /**
* 高级过滤面板组件 * 高级过滤面板组件
@@ -31,6 +32,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
const [activeColorCategory, setActiveColorCategory] = useState<string | null>(null); const [activeColorCategory, setActiveColorCategory] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const [selectedCustomTags, setSelectedCustomTags] = useState<string[]>([]);
// 从分析结果中提取动态选项 // 从分析结果中提取动态选项
const getDynamicOptions = useCallback(() => { const getDynamicOptions = useCallback(() => {
@@ -697,6 +699,30 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
)} )}
</div> </div>
{/* 自定义标签筛选 */}
<div className="filter-section">
<h4 className="section-title">
<Tags className="w-4 h-4 mr-2" />
</h4>
<div className="custom-tag-selector-wrapper">
<CustomTagSelector
selectedTagIds={selectedCustomTags}
onSelectionChange={setSelectedCustomTags}
multiple={true}
showCategoryFilter={true}
showSearch={true}
allowCreate={true}
placeholder="选择或创建自定义标签..."
/>
</div>
{selectedCustomTags.length > 0 && (
<div className="selected-tags-info">
{selectedCustomTags.length}
</div>
)}
</div>
<style>{` <style>{`
.filter-panel { .filter-panel {
background: #f9fafb; background: #f9fafb;
@@ -1419,6 +1445,23 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({
margin-bottom: 8px; margin-bottom: 8px;
} }
} }
/* 自定义标签筛选样式 */
.custom-tag-selector-wrapper {
margin-bottom: 8px;
}
.selected-tags-info {
font-size: 12px;
color: #6b7280;
font-style: italic;
margin-top: 4px;
}
.section-title {
display: flex;
align-items: center;
}
`}</style> `}</style>
</div> </div>
); );

View File

@@ -0,0 +1,333 @@
import { invoke } from '@tauri-apps/api/core';
import {
CustomTagCategory,
CustomTag,
CustomTagWithCategory,
TagAssociation,
TagStatistics,
CreateCustomTagCategoryRequest,
UpdateCustomTagCategoryRequest,
CreateCustomTagRequest,
UpdateCustomTagRequest,
TagFilter,
EntityType,
} from '../types/customTag';
/**
* 自定义标签服务
* 遵循 Tauri 开发规范的服务层设计
*/
export class CustomTagService {
// 标签分类管理
/**
* 获取所有标签分类
*/
static async getCategories(activeOnly: boolean = true): Promise<CustomTagCategory[]> {
return invoke('get_custom_tag_categories', { activeOnly });
}
/**
* 创建标签分类
*/
static async createCategory(request: CreateCustomTagCategoryRequest): Promise<CustomTagCategory> {
return invoke('create_custom_tag_category', { request });
}
/**
* 更新标签分类
*/
static async updateCategory(
id: string,
request: UpdateCustomTagCategoryRequest
): Promise<CustomTagCategory | null> {
return invoke('update_custom_tag_category', { id, request });
}
/**
* 删除标签分类
*/
static async deleteCategory(id: string): Promise<boolean> {
return invoke('delete_custom_tag_category', { id });
}
// 标签管理
/**
* 获取标签列表
*/
static async getTags(filter?: TagFilter): Promise<CustomTagWithCategory[]> {
return invoke('get_custom_tags', { filter });
}
/**
* 创建标签
*/
static async createTag(request: CreateCustomTagRequest): Promise<CustomTag> {
return invoke('create_custom_tag', { request });
}
/**
* 更新标签
*/
static async updateTag(id: string, request: UpdateCustomTagRequest): Promise<CustomTag | null> {
return invoke('update_custom_tag', { id, request });
}
/**
* 删除标签
*/
static async deleteTag(id: string): Promise<boolean> {
return invoke('delete_custom_tag', { id });
}
// 标签关联管理
/**
* 为实体添加标签
*/
static async addEntityTag(
tagId: string,
entityType: EntityType,
entityId: string
): Promise<TagAssociation> {
return invoke('add_entity_tag', {
tagId,
entityType: entityType.toString(),
entityId,
});
}
/**
* 移除实体标签
*/
static async removeEntityTag(
tagId: string,
entityType: EntityType,
entityId: string
): Promise<boolean> {
return invoke('remove_entity_tag', {
tagId,
entityType: entityType.toString(),
entityId,
});
}
/**
* 获取实体的标签
*/
static async getEntityTags(
entityType: EntityType,
entityId: string
): Promise<CustomTagWithCategory[]> {
return invoke('get_entity_tags', {
entityType: entityType.toString(),
entityId,
});
}
/**
* 批量为实体添加标签
*/
static async batchAddEntityTags(
tagIds: string[],
entityType: EntityType,
entityId: string
): Promise<TagAssociation[]> {
return invoke('batch_add_entity_tags', {
tagIds,
entityType: entityType.toString(),
entityId,
});
}
/**
* 批量移除实体标签
*/
static async batchRemoveEntityTags(
tagIds: string[],
entityType: EntityType,
entityId: string
): Promise<number> {
return invoke('batch_remove_entity_tags', {
tagIds,
entityType: entityType.toString(),
entityId,
});
}
// 统计信息
/**
* 获取标签统计信息
*/
static async getStatistics(): Promise<TagStatistics> {
return invoke('get_tag_statistics');
}
// 便捷方法
/**
* 根据分类获取标签
*/
static async getTagsByCategory(categoryId: string): Promise<CustomTagWithCategory[]> {
return this.getTags({
category_ids: [categoryId],
active_only: true,
});
}
/**
* 搜索标签
*/
static async searchTags(query: string): Promise<CustomTagWithCategory[]> {
return this.getTags({
name_search: query,
active_only: true,
});
}
/**
* 获取热门标签(按使用次数排序)
*/
static async getPopularTags(limit: number = 10): Promise<CustomTagWithCategory[]> {
const allTags = await this.getTags({ active_only: true });
return allTags
.sort((a, b) => b.tag.usage_count - a.tag.usage_count)
.slice(0, limit);
}
/**
* 获取最近创建的标签
*/
static async getRecentTags(limit: number = 10): Promise<CustomTagWithCategory[]> {
const allTags = await this.getTags({ active_only: true });
return allTags
.sort((a, b) => new Date(b.tag.created_at).getTime() - new Date(a.tag.created_at).getTime())
.slice(0, limit);
}
/**
* 检查标签名称是否已存在
*/
static async isTagNameExists(name: string, categoryId: string, excludeId?: string): Promise<boolean> {
const tags = await this.getTagsByCategory(categoryId);
return tags.some(tag =>
tag.tag.name.toLowerCase() === name.toLowerCase() &&
tag.tag.id !== excludeId
);
}
/**
* 检查分类名称是否已存在
*/
static async isCategoryNameExists(name: string, excludeId?: string): Promise<boolean> {
const categories = await this.getCategories(false);
return categories.some(category =>
category.name.toLowerCase() === name.toLowerCase() &&
category.id !== excludeId
);
}
/**
* 获取实体类型的所有标签(去重)
*/
static async getTagsByEntityType(entityType: EntityType): Promise<CustomTagWithCategory[]> {
return this.getTags({
entity_type: entityType.toString(),
active_only: true,
});
}
/**
* 同步实体标签(替换现有标签)
*/
static async syncEntityTags(
tagIds: string[],
entityType: EntityType,
entityId: string
): Promise<void> {
// 获取当前标签
const currentTags = await this.getEntityTags(entityType, entityId);
const currentTagIds = currentTags.map(t => t.tag.id);
// 计算需要添加和删除的标签
const toAdd = tagIds.filter(id => !currentTagIds.includes(id));
const toRemove = currentTagIds.filter(id => !tagIds.includes(id));
// 执行批量操作
if (toAdd.length > 0) {
await this.batchAddEntityTags(toAdd, entityType, entityId);
}
if (toRemove.length > 0) {
await this.batchRemoveEntityTags(toRemove, entityType, entityId);
}
}
/**
* 获取标签的颜色(优先使用标签自定义颜色,否则使用分类颜色)
*/
static getTagColor(tag: CustomTagWithCategory): string {
return tag.tag.color || tag.category.color;
}
/**
* 格式化标签显示名称
*/
static formatTagDisplayName(tag: CustomTagWithCategory, showCategory: boolean = false): string {
if (showCategory) {
return `${tag.category.name} / ${tag.tag.name}`;
}
return tag.tag.name;
}
/**
* 验证标签数据
*/
static validateTagData(data: Partial<CreateCustomTagRequest>): string[] {
const errors: string[] = [];
if (!data.name || data.name.trim().length === 0) {
errors.push('标签名称不能为空');
}
if (data.name && data.name.length > 50) {
errors.push('标签名称不能超过50个字符');
}
if (!data.category_id || data.category_id.trim().length === 0) {
errors.push('必须选择标签分类');
}
if (data.description && data.description.length > 200) {
errors.push('标签描述不能超过200个字符');
}
return errors;
}
/**
* 验证分类数据
*/
static validateCategoryData(data: Partial<CreateCustomTagCategoryRequest>): string[] {
const errors: string[] = [];
if (!data.name || data.name.trim().length === 0) {
errors.push('分类名称不能为空');
}
if (data.name && data.name.length > 30) {
errors.push('分类名称不能超过30个字符');
}
if (data.description && data.description.length > 200) {
errors.push('分类描述不能超过200个字符');
}
if (data.color && !/^#[0-9A-Fa-f]{6}$/.test(data.color)) {
errors.push('颜色格式不正确,请使用十六进制格式(如:#3b82f6');
}
return errors;
}
}

View File

@@ -0,0 +1,284 @@
/**
* 自定义标签相关类型定义
* 遵循 Tauri 开发规范的类型安全设计
*/
export interface CustomTagCategory {
/** 分类ID */
id: string;
/** 分类名称 */
name: string;
/** 分类描述 */
description?: string;
/** 分类颜色(十六进制) */
color: string;
/** 分类图标 */
icon?: string;
/** 排序顺序 */
sort_order: number;
/** 是否激活 */
is_active: boolean;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at: string;
}
export interface CustomTag {
/** 标签ID */
id: string;
/** 所属分类ID */
category_id: string;
/** 标签名称 */
name: string;
/** 标签描述 */
description?: string;
/** 标签颜色(可选,继承分类颜色) */
color?: string;
/** 排序顺序 */
sort_order: number;
/** 使用次数 */
usage_count: number;
/** 是否激活 */
is_active: boolean;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at: string;
}
export interface TagAssociation {
/** 关联ID */
id: string;
/** 标签ID */
tag_id: string;
/** 实体类型 */
entity_type: string;
/** 实体ID */
entity_id: string;
/** 创建时间 */
created_at: string;
}
export interface CustomTagWithCategory {
/** 标签信息 */
tag: CustomTag;
/** 分类信息 */
category: CustomTagCategory;
}
export interface CreateCustomTagCategoryRequest {
/** 分类名称 */
name: string;
/** 分类描述 */
description?: string;
/** 分类颜色 */
color?: string;
/** 分类图标 */
icon?: string;
}
export interface UpdateCustomTagCategoryRequest {
/** 分类名称 */
name?: string;
/** 分类描述 */
description?: string;
/** 分类颜色 */
color?: string;
/** 分类图标 */
icon?: string;
/** 排序顺序 */
sort_order?: number;
/** 是否激活 */
is_active?: boolean;
}
export interface CreateCustomTagRequest {
/** 分类ID */
category_id: string;
/** 标签名称 */
name: string;
/** 标签描述 */
description?: string;
/** 标签颜色 */
color?: string;
}
export interface UpdateCustomTagRequest {
/** 标签名称 */
name?: string;
/** 标签描述 */
description?: string;
/** 标签颜色 */
color?: string;
/** 排序顺序 */
sort_order?: number;
/** 是否激活 */
is_active?: boolean;
}
export interface TagFilter {
/** 分类ID列表 */
category_ids?: string[];
/** 标签名称搜索 */
name_search?: string;
/** 是否只显示激活的标签 */
active_only?: boolean;
/** 实体类型过滤 */
entity_type?: string;
/** 实体ID过滤 */
entity_id?: string;
}
export interface TagStatistics {
/** 总标签数 */
total_tags: number;
/** 激活标签数 */
active_tags: number;
/** 总分类数 */
total_categories: number;
/** 激活分类数 */
active_categories: number;
/** 总关联数 */
total_associations: number;
/** 按分类统计 */
by_category: CategoryTagCount[];
}
export interface CategoryTagCount {
/** 分类信息 */
category: CustomTagCategory;
/** 标签数量 */
tag_count: number;
/** 关联数量 */
association_count: number;
}
export enum EntityType {
Material = 'material',
Model = 'model',
Project = 'project',
Template = 'template',
MaterialSegment = 'material_segment',
}
export interface TagManagerProps {
/** 实体类型 */
entityType?: EntityType;
/** 实体ID */
entityId?: string;
/** 是否显示统计信息 */
showStatistics?: boolean;
/** 是否允许创建新标签 */
allowCreate?: boolean;
/** 是否允许编辑标签 */
allowEdit?: boolean;
/** 是否允许删除标签 */
allowDelete?: boolean;
/** 标签变化回调 */
onTagsChange?: (tags: CustomTagWithCategory[]) => void;
}
export interface TagSelectorProps {
/** 已选择的标签ID列表 */
selectedTagIds: string[];
/** 标签选择变化回调 */
onSelectionChange: (tagIds: string[]) => void;
/** 是否允许多选 */
multiple?: boolean;
/** 是否显示分类筛选 */
showCategoryFilter?: boolean;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 是否允许创建新标签 */
allowCreate?: boolean;
/** 占位符文本 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
}
export interface TagEditorProps {
/** 标签ID编辑模式 */
tagId?: string;
/** 分类ID创建模式 */
categoryId?: string;
/** 是否显示 */
visible: boolean;
/** 关闭回调 */
onClose: () => void;
/** 保存成功回调 */
onSave: (tag: CustomTag) => void;
}
export interface CategoryEditorProps {
/** 分类ID编辑模式 */
categoryId?: string;
/** 是否显示 */
visible: boolean;
/** 关闭回调 */
onClose: () => void;
/** 保存成功回调 */
onSave: (category: CustomTagCategory) => void;
}
export interface TagDisplayProps {
/** 标签信息 */
tag: CustomTagWithCategory;
/** 是否显示删除按钮 */
showRemove?: boolean;
/** 删除回调 */
onRemove?: (tagId: string) => void;
/** 点击回调 */
onClick?: (tag: CustomTagWithCategory) => void;
/** 大小 */
size?: 'small' | 'medium' | 'large';
/** 是否可点击 */
clickable?: boolean;
}
export interface TagListProps {
/** 标签列表 */
tags: CustomTagWithCategory[];
/** 是否显示分类分组 */
groupByCategory?: boolean;
/** 是否显示删除按钮 */
showRemove?: boolean;
/** 删除回调 */
onRemove?: (tagId: string) => void;
/** 标签点击回调 */
onTagClick?: (tag: CustomTagWithCategory) => void;
/** 是否显示空状态 */
showEmpty?: boolean;
/** 空状态文本 */
emptyText?: string;
}
// 默认的标签分类颜色
export const DEFAULT_CATEGORY_COLORS = [
'#3b82f6', // 蓝色
'#ef4444', // 红色
'#8b5cf6', // 紫色
'#10b981', // 绿色
'#f59e0b', // 橙色
'#6366f1', // 靛蓝
'#ec4899', // 粉色
'#14b8a6', // 青色
'#f97316', // 橙红
'#84cc16', // 青绿
];
// 默认的标签分类图标
export const DEFAULT_CATEGORY_ICONS = [
'👕', '🎨', '✨', '🌍', '🧵', '🏷️',
'💎', '🎯', '🔥', '⭐', '🎪', '🎭',
];
// 实体类型显示名称映射
export const ENTITY_TYPE_NAMES: Record<EntityType, string> = {
[EntityType.Material]: '素材',
[EntityType.Model]: '模特',
[EntityType.Project]: '项目',
[EntityType.Template]: '模板',
[EntityType.MaterialSegment]: '素材片段',
};