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

资源详情API集成完成

日期: 2026-02-08
类型: 功能实现
影响范围: 前端 + 后端

概述

在资源面板点击角色/场景/道具时,预览面板现在会调用真实的详情API,展示完整的资源信息和标签列表(每个标签一个缩略图)。

问题背景

之前的实现存在以下问题:

  1. 前端 PreviewPanel 使用 mock 数据,未调用真实API
  2. 类型定义不匹配后端,使用了可选字段(?:)而非后端的非null字段
  3. 数据流不完整,从资源列表点击后无法获取标签详情

解决方案

后端实现

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 渲染缩略图网格

验证方法

手动测试

  1. 启动后端:

    cd server
    python -m uvicorn app.main:app --reload
    
  2. 启动前端:

    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测试

# 获取角色详情
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时使用虚拟列表

参考文档