This commit is contained in:
root
2025-07-11 15:45:24 +08:00
parent ebdcaee5a4
commit 231b28d0eb
3 changed files with 534 additions and 5 deletions

View File

@@ -0,0 +1,177 @@
# 项目详情页面素材分类筛选功能指南
## 🎯 功能概述
在项目详情页面的素材管理区域,新增了素材分类筛选功能,允许用户根据预设的分类快速筛选和查找项目中的素材。
## 🚀 功能特点
### 1. 智能分类匹配
- **标签匹配**: 根据素材的标签自动匹配分类
- **文件名匹配**: 根据文件名中的关键词匹配分类
- **AI提示匹配**: 利用分类的AI识别提示进行智能匹配
### 2. 实时数量统计
- **全部分类**: 显示项目中所有素材的总数
- **具体分类**: 显示每个分类下匹配的素材数量
- **动态更新**: 素材变化时自动更新统计数据
### 3. 视觉化标识
- **颜色标识**: 每个分类按钮使用对应的分类颜色
- **选中状态**: 当前选中的分类高亮显示
- **数量显示**: 分类名称后显示匹配的素材数量
## 📍 使用位置
在项目详情页面的素材管理中心,筛选器区域包含:
- 使用状态筛选(全部/已使用/未使用)
- 模特标签筛选
- 类型筛选(全部/原始/分镜)
- **素材分类筛选**(新增功能)
## 🔧 使用方法
### 1. 查看分类列表
1. 进入任意项目的详情页面
2. 在素材管理区域找到筛选器
3. 查看"分类:"标签下的分类按钮列表
### 2. 筛选特定分类
1. 点击任意分类按钮(如"商业 (5)"
2. 素材列表将只显示属于该分类的素材
3. 分类按钮会高亮显示当前选中状态
### 3. 查看所有素材
1. 点击"全部"按钮
2. 清除分类筛选,显示所有素材
### 4. 组合筛选
- 可以同时使用分类筛选和其他筛选条件
- 例如:选择"商业"分类 + "已使用"状态
- 筛选条件会叠加生效
## 🎨 分类匹配规则
### 1. 标签匹配(优先级最高)
- 素材标签完全匹配分类名称
- 素材标签包含分类名称关键词
**示例**:
- 分类:"商业"
- 匹配标签:["商业", "商业宣传", "商务"]
### 2. 文件名匹配
- 文件名包含分类名称
- 文件名包含分类相关关键词
**示例**:
- 分类:"教育"
- 匹配文件名:`教育培训视频.mp4`, `education_demo.mp4`
### 3. AI提示匹配
- 根据分类的AI识别提示中的关键词匹配
- 支持多关键词匹配
**示例**:
- 分类:"科技"AI提示"科技,技术,创新,数字化"
- 匹配素材:包含"技术"、"创新"等关键词的素材
## 📊 数量统计说明
### 统计规则
- **全部**: 项目中所有素材的总数
- **具体分类**: 符合该分类匹配规则的素材数量
- **实时更新**: 添加/删除素材时自动更新
### 显示格式
```
全部 (15) # 总共15个素材
商业 (5) # 5个商业类素材
教育 (3) # 3个教育类素材
科技 (2) # 2个科技类素材
其他 (5) # 5个其他类素材
```
## 🎯 最佳实践
### 1. 素材标签规范
- **统一命名**: 使用标准的分类名称作为标签
- **多标签**: 一个素材可以有多个相关标签
- **中英文**: 支持中英文标签匹配
**推荐标签示例**:
```
商业素材: ["商业", "business", "商务", "企业"]
教育素材: ["教育", "education", "培训", "学习"]
科技素材: ["科技", "technology", "技术", "创新"]
```
### 2. 文件命名规范
- **包含关键词**: 文件名包含分类相关关键词
- **描述性命名**: 使用有意义的文件名
- **避免特殊字符**: 使用标准字符命名
**推荐文件名示例**:
```
商业_产品介绍_2024.mp4
教育_在线课程_demo.mp4
科技_AI演示_v1.mp4
```
### 3. 分类管理建议
- **合理分类**: 创建符合项目需求的分类
- **颜色区分**: 为不同分类设置不同颜色
- **AI提示**: 设置准确的AI识别关键词
## 🔍 故障排除
### 问题1: 素材没有被正确分类
**可能原因**:
- 素材标签不匹配分类名称
- 文件名不包含相关关键词
- 分类的AI提示设置不准确
**解决方案**:
1. 检查素材标签,添加相关分类标签
2. 重命名文件,包含分类关键词
3. 更新分类的AI识别提示
### 问题2: 分类数量显示不正确
**可能原因**:
- 匹配规则过于宽泛或严格
- 素材数据未及时更新
**解决方案**:
1. 刷新页面重新加载数据
2. 检查分类匹配规则设置
3. 验证素材标签和文件名
### 问题3: 分类加载失败
**可能原因**:
- 网络连接问题
- 分类服务异常
**解决方案**:
1. 检查网络连接
2. 刷新页面重试
3. 查看浏览器控制台错误信息
## 🚀 未来扩展
### 计划功能
- **自动分类**: AI自动为新上传的素材分配分类
- **批量分类**: 批量修改素材的分类标签
- **分类统计**: 更详细的分类使用统计报告
- **自定义分类**: 项目级别的自定义分类
### 性能优化
- **缓存机制**: 缓存分类数据减少加载时间
- **懒加载**: 大量分类时的分页加载
- **搜索功能**: 分类名称搜索功能
---
通过素材分类筛选功能,用户可以更高效地管理和查找项目中的素材,提升工作效率!

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react'
import { Video, Play, Search, Filter, Tag, Clock, HardDrive, Eye, User } from 'lucide-react'
import React, { useState, useEffect } from 'react'
import { Video, Play, Search, Filter, Tag, Clock, HardDrive, Eye, User, Folder } from 'lucide-react'
import { Project } from '../services/projectService'
import { VideoSegment, MediaService } from '../services/mediaService'
import { Model } from '../services/modelService'
import { ResourceCategory, ResourceCategoryService } from '../services/resourceCategoryService'
import VideoPlayer from './VideoPlayer'
interface ProjectMaterialsCenterProps {
@@ -25,6 +26,52 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
const [selectedMaterial, setSelectedMaterial] = useState<VideoSegment | null>(null)
const [showVideoPlayer, setShowVideoPlayer] = useState(false)
// 素材分类相关状态
const [categories, setCategories] = useState<ResourceCategory[]>([])
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('')
const [loadingCategories, setLoadingCategories] = useState(false)
// 加载分类数据
useEffect(() => {
loadCategories()
}, [])
const loadCategories = async () => {
setLoadingCategories(true)
try {
const response = await ResourceCategoryService.getAllCategories()
if (response.status && response.data) {
setCategories(response.data.filter(cat => cat.is_active))
}
} catch (error) {
console.error('Failed to load categories:', error)
} finally {
setLoadingCategories(false)
}
}
// 计算每个分类的素材数量
const getCategoryMaterialCount = (categoryId: string): number => {
if (!categoryId) return materials.length // 全部分类
const category = categories.find(cat => cat.id === categoryId)
if (!category) return 0
return materials.filter(material => {
const categoryTitle = category.title.toLowerCase()
const materialTags = (material.tags || []).map(tag => tag.toLowerCase())
const filename = material.filename.toLowerCase()
return materialTags.includes(categoryTitle) ||
materialTags.some(tag => tag.includes(categoryTitle)) ||
filename.includes(categoryTitle) ||
Boolean(category.ai_prompt &&
category.ai_prompt.toLowerCase().split(/[,,、\s]+/)
.some(keyword => keyword.length > 1 &&
(materialTags.includes(keyword) || filename.includes(keyword))))
}).length
}
// 过滤素材
const filteredMaterials = materials.filter(material => {
// 搜索过滤
@@ -54,7 +101,28 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
matchesUsage = material.use_count === 0
}
return matchesSearch && matchesModel && matchesType && matchesUsage
// 分类过滤
let matchesCategory = true
if (selectedCategoryId) {
const category = categories.find(cat => cat.id === selectedCategoryId)
if (category) {
// 检查素材标签是否包含分类标题,或者文件名是否包含分类相关关键词
const categoryTitle = category.title.toLowerCase()
const materialTags = (material.tags || []).map(tag => tag.toLowerCase())
const filename = material.filename.toLowerCase()
matchesCategory = materialTags.includes(categoryTitle) ||
materialTags.some(tag => tag.includes(categoryTitle)) ||
filename.includes(categoryTitle) ||
// 检查AI提示中的关键词
Boolean(category.ai_prompt &&
category.ai_prompt.toLowerCase().split(/[,,、\s]+/)
.some(keyword => keyword.length > 1 &&
(materialTags.includes(keyword) || filename.includes(keyword))))
}
}
return matchesSearch && matchesModel && matchesType && matchesUsage && matchesCategory
})
const handleModelToggle = (modelId: string) => {
@@ -213,6 +281,53 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
))}
</div>
</div>
{/* 素材分类过滤 */}
<div className="flex items-center space-x-3">
<div className="flex items-center">
<Folder size={14} className="text-gray-400 mr-1" />
<span className="text-xs font-medium text-gray-700">:</span>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedCategoryId('')}
className={`px-2 py-1 text-xs rounded transition-colors ${
selectedCategoryId === ''
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
({getCategoryMaterialCount('')})
</button>
{loadingCategories ? (
<div className="px-2 py-1 text-xs text-gray-500">...</div>
) : (
categories.map((category) => {
const count = getCategoryMaterialCount(category.id)
return (
<button
key={category.id}
onClick={() => setSelectedCategoryId(category.id)}
className={`px-2 py-1 text-xs rounded transition-colors ${
selectedCategoryId === category.id
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
style={{
backgroundColor: selectedCategoryId === category.id
? undefined
: `${category.color}20`,
borderColor: category.color,
borderWidth: '1px'
}}
>
{category.title} ({count})
</button>
)
})
)}
</div>
</div>
</div>
</div>
@@ -222,13 +337,13 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
<div className="text-center py-8">
<Video className="mx-auto text-gray-400 mb-3" size={48} />
<h3 className="text-base font-medium text-gray-900 mb-1">
{searchTerm || selectedModelIds.length > 0 || filterType !== 'all' || usageFilter !== 'all'
{searchTerm || selectedModelIds.length > 0 || filterType !== 'all' || usageFilter !== 'all' || selectedCategoryId
? '没有找到匹配的素材'
: '暂无项目素材'
}
</h3>
<p className="text-sm text-gray-600">
{searchTerm || selectedModelIds.length > 0 || filterType !== 'all' || usageFilter !== 'all'
{searchTerm || selectedModelIds.length > 0 || filterType !== 'all' || usageFilter !== 'all' || selectedCategoryId
? '尝试调整搜索条件或筛选器'
: `上传包含"${project.product_name}"标签的视频素材`
}

View File

@@ -0,0 +1,237 @@
/**
* 素材分类匹配工具
* 根据文件信息和分类规则自动匹配素材分类
*/
import { VideoSegment } from '../services/mediaService'
import { ResourceCategory } from '../services/resourceCategoryService'
export interface MaterialCategoryMatch {
categoryId: string
confidence: number // 匹配置信度 0-1
reason: string // 匹配原因
}
export class MaterialCategoryMatcher {
/**
* 根据文件扩展名匹配分类
*/
static matchByFileExtension(filename: string, categories: ResourceCategory[]): MaterialCategoryMatch[] {
const extension = filename.toLowerCase().split('.').pop() || ''
const matches: MaterialCategoryMatch[] = []
for (const category of categories) {
// 检查分类的AI识别提示中是否包含文件扩展名相关的关键词
const prompt = category.ai_recognition_prompt?.toLowerCase() || ''
if (prompt.includes(extension) ||
prompt.includes('视频') && ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv'].includes(extension) ||
prompt.includes('音频') && ['mp3', 'wav', 'aac', 'flac', 'm4a'].includes(extension) ||
prompt.includes('图片') && ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(extension)) {
matches.push({
categoryId: category.id,
confidence: 0.7,
reason: `文件扩展名匹配: ${extension}`
})
}
}
return matches
}
/**
* 根据文件名关键词匹配分类
*/
static matchByFilename(filename: string, categories: ResourceCategory[]): MaterialCategoryMatch[] {
const matches: MaterialCategoryMatch[] = []
const filenameLower = filename.toLowerCase()
for (const category of categories) {
const categoryTitle = category.title.toLowerCase()
const prompt = category.ai_recognition_prompt?.toLowerCase() || ''
// 检查文件名是否包含分类标题
if (filenameLower.includes(categoryTitle)) {
matches.push({
categoryId: category.id,
confidence: 0.9,
reason: `文件名包含分类名称: ${category.title}`
})
continue
}
// 检查文件名是否包含AI识别提示中的关键词
const keywords = prompt.split(/[,,、\s]+/).filter(word => word.length > 1)
for (const keyword of keywords) {
if (filenameLower.includes(keyword)) {
matches.push({
categoryId: category.id,
confidence: 0.6,
reason: `文件名包含关键词: ${keyword}`
})
break
}
}
}
return matches
}
/**
* 根据标签匹配分类
*/
static matchByTags(tags: string[], categories: ResourceCategory[]): MaterialCategoryMatch[] {
const matches: MaterialCategoryMatch[] = []
for (const category of categories) {
const categoryTitle = category.title.toLowerCase()
const prompt = category.ai_recognition_prompt?.toLowerCase() || ''
for (const tag of tags) {
const tagLower = tag.toLowerCase()
// 检查标签是否与分类标题匹配
if (tagLower === categoryTitle || tagLower.includes(categoryTitle)) {
matches.push({
categoryId: category.id,
confidence: 0.95,
reason: `标签匹配分类: ${tag}`
})
continue
}
// 检查标签是否与AI识别提示匹配
const keywords = prompt.split(/[,,、\s]+/).filter(word => word.length > 1)
for (const keyword of keywords) {
if (tagLower.includes(keyword)) {
matches.push({
categoryId: category.id,
confidence: 0.8,
reason: `标签包含关键词: ${keyword}`
})
break
}
}
}
}
return matches
}
/**
* 根据素材路径匹配分类
*/
static matchByPath(filePath: string, categories: ResourceCategory[]): MaterialCategoryMatch[] {
const matches: MaterialCategoryMatch[] = []
const pathLower = filePath.toLowerCase()
for (const category of categories) {
const categoryTitle = category.title.toLowerCase()
// 检查路径是否包含分类名称
if (pathLower.includes(`/${categoryTitle}/`) ||
pathLower.includes(`\\${categoryTitle}\\`) ||
pathLower.includes(categoryTitle)) {
matches.push({
categoryId: category.id,
confidence: 0.8,
reason: `路径包含分类名称: ${category.title}`
})
}
}
return matches
}
/**
* 综合匹配素材分类
*/
static matchMaterialCategory(material: VideoSegment, categories: ResourceCategory[]): MaterialCategoryMatch[] {
const allMatches: MaterialCategoryMatch[] = []
// 按文件扩展名匹配
allMatches.push(...this.matchByFileExtension(material.filename, categories))
// 按文件名匹配
allMatches.push(...this.matchByFilename(material.filename, categories))
// 按标签匹配
if (material.tags && material.tags.length > 0) {
allMatches.push(...this.matchByTags(material.tags, categories))
}
// 按路径匹配
if (material.file_path) {
allMatches.push(...this.matchByPath(material.file_path, categories))
}
// 合并相同分类的匹配结果,取最高置信度
const categoryMatches = new Map<string, MaterialCategoryMatch>()
for (const match of allMatches) {
const existing = categoryMatches.get(match.categoryId)
if (!existing || match.confidence > existing.confidence) {
categoryMatches.set(match.categoryId, match)
}
}
// 按置信度排序
return Array.from(categoryMatches.values()).sort((a, b) => b.confidence - a.confidence)
}
/**
* 获取素材的最佳匹配分类
*/
static getBestMatchCategory(material: VideoSegment, categories: ResourceCategory[]): ResourceCategory | null {
const matches = this.matchMaterialCategory(material, categories)
if (matches.length === 0) {
return null
}
const bestMatch = matches[0]
// 只有置信度超过阈值才返回匹配结果
if (bestMatch.confidence >= 0.5) {
return categories.find(cat => cat.id === bestMatch.categoryId) || null
}
return null
}
/**
* 检查素材是否属于指定分类
*/
static isMaterialInCategory(material: VideoSegment, categoryId: string, categories: ResourceCategory[]): boolean {
const matches = this.matchMaterialCategory(material, categories)
const categoryMatch = matches.find(match => match.categoryId === categoryId)
// 置信度超过0.3就认为属于该分类
return categoryMatch ? categoryMatch.confidence >= 0.3 : false
}
/**
* 获取分类的素材统计
*/
static getCategoryMaterialStats(materials: VideoSegment[], categories: ResourceCategory[]): Map<string, number> {
const stats = new Map<string, number>()
// 初始化所有分类的计数
for (const category of categories) {
stats.set(category.id, 0)
}
// 统计每个分类的素材数量
for (const material of materials) {
const matches = this.matchMaterialCategory(material, categories)
for (const match of matches) {
if (match.confidence >= 0.3) {
const current = stats.get(match.categoryId) || 0
stats.set(match.categoryId, current + 1)
}
}
}
return stats
}
}