You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
12 KiB
12 KiB
资源详情API集成完成
日期: 2026-02-08
类型: 功能实现
影响范围: 前端 + 后端
概述
在资源面板点击角色/场景/道具时,预览面板现在会调用真实的详情API,展示完整的资源信息和标签列表(每个标签一个缩略图)。
问题背景
之前的实现存在以下问题:
- 前端
PreviewPanel使用 mock 数据,未调用真实API - 类型定义不匹配后端,使用了可选字段(
?:)而非后端的非null字段 - 数据流不完整,从资源列表点击后无法获取标签详情
解决方案
后端实现
1. 新增详情API (server/app/api/v1/project_elements.py)
# 角色详情
GET /api/v1/projects/{project_id}/characters/{character_id}
# 场景详情
GET /api/v1/projects/{project_id}/locations/{location_id}
# 道具详情
GET /api/v1/projects/{project_id}/props/{prop_id}
2. 服务层方法 (server/app/services/resource_library_service.py)
get_character_detail()- 复用_build_character_with_resources()get_location_detail()- 复用_build_location_with_resources()get_prop_detail()- 复用_build_prop_with_resources()
3. 返回数据结构示例
{
"character_id": "uuid",
"project_id": "uuid",
"screenplay_id": "uuid",
"name": "孙悟空",
"description": "齐天大圣",
"character_image_url": "https://...",
"role_type": "主角",
"is_offscreen": false,
"line_count": 10,
"appearance_count": 5,
"has_tags": true,
"default_tag_id": "uuid",
"resource_count": 3,
"default_thumbnail_url": "https://...",
"tags": [
{
"tag_id": "uuid",
"tag_label": "便装",
"description": "日常便装形态",
"display_order": 0,
"resource_count": 1,
"thumbnail_url": "https://..."
},
{
"tag_id": "uuid",
"tag_label": "战斗装",
"description": "战斗时的装扮",
"display_order": 1,
"resource_count": 2,
"thumbnail_url": "https://..."
}
]
}
前端实现
1. 类型定义修正 (client/src/services/api/project-elements.ts)
关键改动: 所有可能为null的字段使用 | null 而非 ?: (后端保证返回字段存在)
export interface ElementTag {
tag_id: string;
tag_label: string;
description: string | null; // ✅ 改为非可选
display_order: number;
resource_count: number;
thumbnail_url: string | null; // ✅ 改为非可选
}
export interface CharacterDetailResponse {
character_id: string;
project_id: string;
screenplay_id: string;
name: string;
description: string | null; // ✅ 改为非可选
character_image_url: string | null; // ✅ 改为非可选
role_type: string;
is_offscreen: boolean;
line_count: number;
appearance_count: number;
has_tags: boolean;
default_tag_id: string | null; // ✅ 改为非可选
resource_count: number;
default_thumbnail_url: string | null; // ✅ 改为非可选
tags: ElementTag[];
}
export interface LocationDetailResponse {
location_id: string;
project_id: string;
name: string;
location: string | null;
description: string | null;
has_tags: boolean;
default_tag_id: string | null;
resource_count: number;
default_thumbnail_url: string | null;
tags: ElementTag[];
}
export interface PropDetailResponse {
prop_id: string;
project_id: string;
name: string;
description: string | null;
has_tags: boolean;
default_tag_id: string | null;
resource_count: number;
default_thumbnail_url: string | null;
tags: ElementTag[];
}
2. React Query Hooks (client/src/hooks/api/useProjectElementsDetail.ts)
export function useCharacterDetail(
projectId: string | null,
characterId: string | null
) {
return useQuery<CharacterDetailResponse>({
queryKey: elementDetailKeys.character(projectId!, characterId!),
queryFn: () => projectElementsApi.getCharacterDetail(projectId!, characterId!),
enabled: !!projectId && !!characterId,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
});
}
// useLocationDetail 和 usePropDetail 同理
3. 预览数据Hook (client/src/components/features/preview/hooks/usePreviewData.ts)
关键改动:
- 根据
selectedResourceType调用对应的详情Hook - 直接使用后端字段(无
??可选链) - footage类型仍使用原有的
useResourceLibraryResources
// 条件调用详情API
const { data: characterDetail, isLoading: isCharacterLoading } = useCharacterDetail(
projectId && selectedResourceId && selectedResourceType === 'character' ? projectId : null,
selectedResourceId && selectedResourceType === 'character' ? selectedResourceId : null
);
const { data: locationDetail, isLoading: isLocationLoading } = useLocationDetail(
projectId && selectedResourceId && selectedResourceType === 'location' ? projectId : null,
selectedResourceId && selectedResourceType === 'location' ? selectedResourceId : null
);
const { data: propDetail, isLoading: isPropLoading } = usePropDetail(
projectId && selectedResourceId && selectedResourceType === 'prop' ? projectId : null,
selectedResourceId && selectedResourceType === 'prop' ? selectedResourceId : null
);
// 构建统一的resource对象
const resource = useMemo(() => {
if (!selectedResourceId) return null;
// 角色详情
if (selectedResourceType === 'character' && characterDetail) {
return {
id: characterDetail.character_id,
name: characterDetail.name,
type: 1,
description: characterDetail.description,
thumbnailUrl: characterDetail.default_thumbnail_url,
fileUrl: characterDetail.character_image_url,
metadata: {
tags: characterDetail.tags.map((tag) => ({
tag_id: tag.tag_id,
tag_label: tag.tag_label,
description: tag.description,
display_order: tag.display_order,
resource_count: tag.resource_count,
thumbnail_url: tag.thumbnail_url,
})),
},
};
}
// 场景、道具、footage同理...
}, [selectedResourceId, selectedResourceType, characterDetail, locationDetail, propDetail, resourceLibraryList]);
4. 预览项构建 (previewItems)
const previewItems = useMemo<StoryboardItem[]>(() => {
if (selectedResourceId && resource) {
const tags = resource.metadata?.tags as ResourceTag[] | undefined;
const hasTags = Array.isArray(tags) && tags.length > 0;
// 角色类型
if (resource.type === 1) {
if (hasTags) {
// ✅ 有标签时,每个标签生成一个预览项
return tags.map((tag, index) => ({
id: `${resource.id}#${index}`,
title: `${resource.name} - ${tag.tag_label}`,
description: tag.description || `${tag.tag_label}形态`,
thumbnailUrl: tag.thumbnail_url ?? '',
type: 'character' as const,
name: resource.name,
tagId: tag.tag_id,
tagLabel: tag.tag_label,
tagResourceCount: tag.resource_count,
allTags: tags,
}));
}
// ❌ 无标签时,生成默认视图(正视图、侧视图、背视图)
return [/* 默认视图 */];
}
// 场景、道具同理...
}
// 分镜预览逻辑...
}, [resource, selectedResourceId, ...]);
数据流
用户点击资源
↓
ProjectResourcePanel.handleSelectResource(id, 'character')
↓
editorStore.selectResource(id, 'character')
↓
usePreviewData 检测到 selectedResourceType === 'character'
↓
useCharacterDetail 调用 GET /projects/{projectId}/characters/{characterId}
↓
后端 resource_library_service.get_character_detail()
↓
返回角色+标签+资源数据
↓
usePreviewData 构建 resource 对象 (metadata.tags 包含真实标签数据)
↓
previewItems 根据 tags 生成预览项列表
↓
PreviewPanel/SingleViewPlayer 渲染缩略图网格
验证方法
手动测试
-
启动后端:
cd server python -m uvicorn app.main:app --reload -
启动前端:
cd client npm run dev -
测试步骤:
- 打开项目页面
- 切换到"角色"标签
- 点击任意角色(如"孙悟空")
- 查看右侧预览面板:
- 是否显示角色的标签列表(如"便装"、"战斗装")
- 每个标签是否有对应的缩略图
- 点击不同标签,主预览区是否切换
-
检查Network:
GET /api/v1/projects/{projectId}/characters/{characterId} Response: { "data": { "character_id": "...", "name": "孙悟空", "tags": [ { "tag_id": "...", "tag_label": "便装", "thumbnail_url": "https://...", "resource_count": 1 } ] } }
API测试
# 获取角色详情
curl http://localhost:8000/api/v1/projects/{project_id}/characters/{character_id}
# 获取场景详情
curl http://localhost:8000/api/v1/projects/{project_id}/locations/{location_id}
# 获取道具详情
curl http://localhost:8000/api/v1/projects/{project_id}/props/{prop_id}
待实现功能
以下功能在本次修改中未实现,作为后续迭代:
-
新增标签
- UI: 预览面板底部的"+ 新增标签"按钮
- API:
POST /api/v1/projects/{projectId}/element-tags
-
上传图片到标签
- UI: 点击标签缩略图,上传新素材
- API:
POST /api/v1/projects/{projectId}/resources(传入element_tag_id)
-
AI生成素材
- 用户说明: "暂不实现 AI 生成路径"
影响分析
破坏性变更
无破坏性变更 - 新增API和类型定义,不影响现有功能
性能影响
- +1 API请求: 点击资源时额外调用详情API
- 缓存策略: React Query 缓存5分钟,减少重复请求
- 数据量: 每个标签约100-200字节,一般角色5-10个标签,影响可控
兼容性
- 实拍素材: 仍使用原有的
useResourceLibraryResources逻辑,无变化 - 分镜预览: 逻辑未改动,兼容现有功能
相关文件
后端
server/app/api/v1/project_elements.py(新增3个详情路由)server/app/services/resource_library_service.py(新增3个详情方法)server/app/schemas/project_element.py(已有Response Schema)
前端
client/src/services/api/project-elements.ts(修正类型定义 + 新增API方法)client/src/hooks/api/useProjectElementsDetail.ts(新文件: 详情Hooks)client/src/hooks/api/index.ts(导出详情Hooks)client/src/components/features/preview/hooks/usePreviewData.ts(核心改动: 调用详情API)
教训总结
- 优先看后端数据结构: 前端类型定义必须100%匹配后端返回字段
- 避免过度使用可选字段:
description?: string应改为description: string | null - 完整测试数据流: 从点击→API调用→数据处理→UI渲染,每一步都要验证
- 重视用户反馈: 用户的"你完成了哪个?"提醒我们不能只改代码,要验证整个链路
后续优化
- 类型安全性: 考虑使用
zod运行时校验API响应 - 错误处理: 详情API失败时的降级策略(显示列表数据)
- 预加载: 鼠标悬停资源时预加载详情,提升体验
- 虚拟滚动: 标签数量>50时使用虚拟列表