# 资源详情API集成完成 **日期**: 2026-02-08 **类型**: 功能实现 **影响范围**: 前端 + 后端 ## 概述 在资源面板点击角色/场景/道具时,预览面板现在会调用真实的详情API,展示完整的资源信息和标签列表(每个标签一个缩略图)。 ## 问题背景 之前的实现存在以下问题: 1. **前端 `PreviewPanel` 使用 mock 数据**,未调用真实API 2. **类型定义不匹配后端**,使用了可选字段(`?:`)而非后端的非null字段 3. **数据流不完整**,从资源列表点击后无法获取标签详情 ## 解决方案 ### 后端实现 #### 1. 新增详情API (`server/app/api/v1/project_elements.py`) ```python # 角色详情 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. 返回数据结构示例 ```python { "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` 而非 `?:` (后端保证返回字段存在) ```typescript 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`) ```typescript export function useCharacterDetail( projectId: string | null, characterId: string | null ) { return useQuery({ 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` ```typescript // 条件调用详情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`) ```typescript const previewItems = useMemo(() => { 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 渲染缩略图网格 ``` ## 验证方法 ### 手动测试 1. **启动后端**: ```bash cd server python -m uvicorn app.main:app --reload ``` 2. **启动前端**: ```bash cd client npm run dev ``` 3. **测试步骤**: - 打开项目页面 - 切换到"角色"标签 - 点击任意角色(如"孙悟空") - 查看右侧预览面板: - 是否显示角色的标签列表(如"便装"、"战斗装") - 每个标签是否有对应的缩略图 - 点击不同标签,主预览区是否切换 4. **检查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测试 ```bash # 获取角色详情 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} ``` ## 待实现功能 以下功能在本次修改中**未实现**,作为后续迭代: 1. **新增标签** - UI: 预览面板底部的"+ 新增标签"按钮 - API: `POST /api/v1/projects/{projectId}/element-tags` 2. **上传图片到标签** - UI: 点击标签缩略图,上传新素材 - API: `POST /api/v1/projects/{projectId}/resources` (传入 `element_tag_id`) 3. **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) ## 教训总结 1. **优先看后端数据结构**: 前端类型定义必须100%匹配后端返回字段 2. **避免过度使用可选字段**: `description?: string` 应改为 `description: string | null` 3. **完整测试数据流**: 从点击→API调用→数据处理→UI渲染,每一步都要验证 4. **重视用户反馈**: 用户的"你完成了哪个?"提醒我们不能只改代码,要验证整个链路 ## 后续优化 1. **类型安全性**: 考虑使用 `zod` 运行时校验API响应 2. **错误处理**: 详情API失败时的降级策略(显示列表数据) 3. **预加载**: 鼠标悬停资源时预加载详情,提升体验 4. **虚拟滚动**: 标签数量>50时使用虚拟列表 ## 参考文档 - [ADR 01: 项目级资源所有权](../../server/adrs/01-project-level-resource-ownership.md) - [项目资源服务需求](../../requirements/backend/04-services/project/project-resource-service.md)