Files
TrueGrowth/specs/009-media-library/quickstart.md

21 KiB
Raw Permalink Blame History

Quick Start: 素材管理库 (Media Library) 开发指南

Feature: 009-media-library Date: 2025-12-11

概览

本指南帮助开发者快速开始实现素材管理库功能。按照优先级顺序进行开发,确保每个阶段都有可工作的增量交付。

前置条件

  • Node.js 18+ 和 npm
  • 熟悉 TypeScript, React 18, 和 React Hooks
  • 了解 IndexedDB 和 localforage
  • 熟悉 TDesign React 组件库
  • 了解项目宪章(constitution.md)和编码标准

开发环境设置

1. 克隆并安装依赖

cd /Users/gongchengtu/code/github/aitu
git checkout 009-media-library

# 安装依赖(如果尚未安装)
npm install

2. 验证开发服务器

npm start  # 启动开发服务器在 localhost:7200

3. 运行测试

npm test  # 运行所有测试

实现顺序(按优先级)

Phase 1: 核心数据层 (P1)

目标: 实现存储服务和数据模型

1.1 创建类型定义

文件: packages/drawnix/src/types/asset.types.ts

// 参考 data-model.md 中的完整定义
export enum AssetType { IMAGE = 'IMAGE', VIDEO = 'VIDEO' }
export enum AssetSource { LOCAL = 'LOCAL', AI_GENERATED = 'AI_GENERATED' }
export interface Asset { /* ... */ }
export interface StoredAsset { /* ... */ }
// ... 其他类型

验收标准:

  • TypeScript strict mode 编译通过
  • 所有枚举和接口导出
  • 文件 < 200 行

1.2 实现存储服务

文件: packages/drawnix/src/services/asset-storage-service.ts

import localforage from 'localforage';
import type { Asset, StoredAsset, AddAssetData } from '../types/asset.types';

class AssetStorageService {
  private store: LocalForage;

  async initialize() {
    this.store = localforage.createInstance({
      name: 'aitu-assets',
      storeName: 'assets'
    });
  }

  async addAsset(data: AddAssetData): Promise<Asset> {
    // 实现逻辑...
  }

  // 其他方法...
}

export const assetStorageService = new AssetStorageService();

验收标准:

  • 所有接口方法实现(参考 contracts/asset-storage-service.md
  • 错误处理完善
  • 单元测试覆盖率 > 80%
  • 文件 < 250 行

1.3 编写存储服务测试

文件: tests/media-library/unit/asset-storage-service.spec.ts

describe('AssetStorageService', () => {
  beforeEach(async () => {
    await assetStorageService.initialize();
    await assetStorageService.clearAll();
  });

  // 参考 contracts/asset-storage-service.md 中的测试用例
});

Phase 2: 状态管理 (P1)

目标: 实现React Context和自定义Hook

2.1 创建AssetContext

文件: packages/drawnix/src/contexts/AssetContext.tsx

import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { assetStorageService } from '../services/asset-storage-service';
import type { Asset, AssetContextValue } from '../types/asset.types';

const AssetContext = createContext<AssetContextValue | null>(null);

export function AssetProvider({ children }: { children: React.ReactNode }) {
  const [assets, setAssets] = useState<Asset[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadAssets = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const loaded = await assetStorageService.getAllAssets();
      setAssets(loaded);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, []);

  const addAsset = useCallback(async (/* ... */) => {
    // 实现...
  }, []);

  // 其他操作...

  const value = useMemo(() => ({
    assets,
    loading,
    error,
    loadAssets,
    addAsset,
    // ... 其他方法
  }), [assets, loading, error, loadAssets, addAsset]);

  return <AssetContext.Provider value={value}>{children}</AssetContext.Provider>;
}

export function useAssets() {
  const context = useContext(AssetContext);
  if (!context) throw new Error('useAssets must be used within AssetProvider');
  return context;
}

验收标准:

  • Context正确提供所有state和actions
  • 使用useMemo和useCallback优化性能
  • 文件 < 150 行

2.2 在应用中集成Provider

文件: packages/drawnix/src/drawnix.tsx (修改)

import { AssetProvider } from './contexts/AssetContext';

export function Drawnix() {
  return (
    <AssetProvider>
      {/* 现有内容 */}
    </AssetProvider>
  );
}

Phase 3: UI 组件 - 基础结构 (P1)

目标: 实现核心UI组件

3.1 MediaLibraryModal容器

文件: packages/drawnix/src/components/media-library/MediaLibraryModal.tsx

import React, { useState, useEffect } from 'react';
import { Dialog } from 'tdesign-react';
import { useAssets } from '../../contexts/AssetContext';
import MediaLibrarySidebar from './MediaLibrarySidebar';
import MediaLibraryGrid from './MediaLibraryGrid';
import MediaLibraryInspector from './MediaLibraryInspector';
import './MediaLibraryModal.scss';

interface MediaLibraryModalProps {
  isOpen: boolean;
  onClose: () => void;
  onSelect?: (asset: Asset) => void;
  filterType?: AssetType;
}

export function MediaLibraryModal({
  isOpen,
  onClose,
  onSelect,
  filterType
}: MediaLibraryModalProps) {
  const { assets, loadAssets } = useAssets();
  const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);

  useEffect(() => {
    if (isOpen) {
      loadAssets();
    }
  }, [isOpen, loadAssets]);

  if (!isOpen) return null;

  return (
    <Dialog
      visible={isOpen}
      onClose={onClose}
      width="90vw"
      height="90vh"
      className="media-library-modal"
    >
      <div className="media-library-layout">
        <MediaLibrarySidebar />
        <MediaLibraryGrid
          filterType={filterType}
          selectedAssetId={selectedAssetId}
          onSelectAsset={setSelectedAssetId}
        />
        <MediaLibraryInspector
          assetId={selectedAssetId}
          onSelect={onSelect}
        />
      </div>
    </Dialog>
  );
}

文件: packages/drawnix/src/components/media-library/MediaLibraryModal.scss

.media-library-modal {
  .media-library-layout {
    display: flex;
    height: 90vh;
    background: var(--color-bg);

    // 使用BEM命名
    &__sidebar {
      width: 260px;
      border-right: 1px solid var(--color-border);
    }

    &__main {
      flex: 1;
      display: flex;
      flex-direction: column;
    }

    &__inspector {
      width: 320px;
      border-left: 1px solid var(--color-border);
    }

    // 响应式
    @media (max-width: 768px) {
      flex-direction: column;

      &__sidebar,
      &__inspector {
        width: 100%;
        border: none;
      }
    }
  }
}

验收标准:

  • 使用TDesign Dialog组件
  • 响应式布局(桌面/移动)
  • 文件 < 150 行tsx, < 100 行scss

3.2 MediaLibraryGrid网格视图

文件: packages/drawnix/src/components/media-library/MediaLibraryGrid.tsx

import React, { useMemo } from 'react';
import { useAssets } from '../../contexts/AssetContext';
import { filterAssets } from '../../utils/asset-utils';
import AssetGridItem from './AssetGridItem';
import MediaLibraryEmpty from './MediaLibraryEmpty';

interface MediaLibraryGridProps {
  filterType?: AssetType;
  selectedAssetId: string | null;
  onSelectAsset: (id: string) => void;
}

export function MediaLibraryGrid({
  filterType,
  selectedAssetId,
  onSelectAsset
}: MediaLibraryGridProps) {
  const { assets, filters } = useAssets();

  // 应用筛选和排序
  const filteredResult = useMemo(() => {
    const merged = filterType
      ? { ...filters, activeType: filterType }
      : filters;
    return filterAssets(assets, merged);
  }, [assets, filters, filterType]);

  if (filteredResult.isEmpty) {
    return <MediaLibraryEmpty />;
  }

  return (
    <div className="media-library-grid">
      <div className="media-library-grid__container">
        {filteredResult.assets.map(asset => (
          <AssetGridItem
            key={asset.id}
            asset={asset}
            isSelected={selectedAssetId === asset.id}
            onSelect={onSelectAsset}
          />
        ))}
      </div>
    </div>
  );
}

验收标准:

  • 使用useMemo优化筛选
  • 正确显示空状态
  • 网格布局响应式
  • 文件 < 150 行

3.3 AssetGridItem网格项

文件: packages/drawnix/src/components/media-library/AssetGridItem.tsx

import React, { useCallback } from 'react';
import { ImageIcon, VideoIcon } from 'lucide-react';
import type { Asset } from '../../types/asset.types';

interface AssetGridItemProps {
  asset: Asset;
  isSelected: boolean;
  onSelect: (id: string) => void;
}

export const AssetGridItem = React.memo<AssetGridItemProps>(({
  asset,
  isSelected,
  onSelect
}) => {
  const handleClick = useCallback(() => {
    onSelect(asset.id);
  }, [asset.id, onSelect]);

  return (
    <div
      className={`asset-grid-item ${isSelected ? 'asset-grid-item--selected' : ''}`}
      onClick={handleClick}
    >
      {/* 缩略图 */}
      <div className="asset-grid-item__thumbnail">
        {asset.type === 'IMAGE' ? (
          <img src={asset.url} alt={asset.name} />
        ) : (
          <video src={asset.url} muted />
        )}
      </div>

      {/* 类型标识 */}
      <div className="asset-grid-item__badge">
        {asset.type === 'IMAGE' ? <ImageIcon /> : <VideoIcon />}
      </div>

      {/* AI标识 */}
      {asset.source === 'AI_GENERATED' && (
        <div className="asset-grid-item__ai-badge">AI</div>
      )}

      {/* 名称 */}
      <div className="asset-grid-item__name">{asset.name}</div>
    </div>
  );
}, (prev, next) => (
  prev.asset.id === next.asset.id && prev.isSelected === next.isSelected
));

验收标准:

  • 使用React.memo优化
  • 自定义比较函数
  • BEM命名的样式
  • 文件 < 120 行

Phase 4: UI 组件 - 侧边栏和详情面板 (P1)

4.1 MediaLibrarySidebar筛选侧边栏

文件: packages/drawnix/src/components/media-library/MediaLibrarySidebar.tsx

import React from 'react';
import { Button } from 'tdesign-react';
import { Grid, ImageIcon, VideoIcon, Cpu, HardDrive } from 'lucide-react';
import { useAssets } from '../../contexts/AssetContext';
import MediaLibraryStorageBar from './MediaLibraryStorageBar';

export function MediaLibrarySidebar() {
  const { filters, setFilters, assets, storageStatus } = useAssets();

  return (
    <div className="media-library-sidebar">
      {/* 类型筛选 */}
      <section className="media-library-sidebar__section">
        <h3 className="media-library-sidebar__title">类型</h3>
        <Button
          variant={filters.activeType === 'ALL' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeType: 'ALL' })}
          block
        >
          <Grid /> 全部素材
        </Button>
        <Button
          variant={filters.activeType === 'IMAGE' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeType: 'IMAGE' })}
          block
        >
          <ImageIcon /> 图片
        </Button>
        <Button
          variant={filters.activeType === 'VIDEO' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeType: 'VIDEO' })}
          block
        >
          <VideoIcon /> 视频
        </Button>
      </section>

      {/* 来源筛选 */}
      <section className="media-library-sidebar__section">
        <h3 className="media-library-sidebar__title">来源</h3>
        <Button
          variant={filters.activeSource === 'ALL' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeSource: 'ALL' })}
          block
        >
          全部来源
        </Button>
        <Button
          variant={filters.activeSource === 'LOCAL' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeSource: 'LOCAL' })}
          block
        >
          <HardDrive /> 本地上传
        </Button>
        <Button
          variant={filters.activeSource === 'AI' ? 'base' : 'outline'}
          onClick={() => setFilters({ activeSource: 'AI' })}
          block
        >
          <Cpu /> AI生成
        </Button>
      </section>

      {/* 存储状态 */}
      <MediaLibraryStorageBar
        assetCount={assets.length}
        storageStatus={storageStatus}
      />
    </div>
  );
}

验收标准:

  • 使用TDesign Button组件light主题
  • 图标来自lucide-react
  • 文件 < 150 行

4.2 MediaLibraryInspector详情面板

文件: packages/drawnix/src/components/media-library/MediaLibraryInspector.tsx

import React, { useState } from 'react';
import { Button, Input, MessagePlugin } from 'tdesign-react';
import { Download, Trash2, Edit2, CheckCircle } from 'lucide-react';
import { useAssets } from '../../contexts/AssetContext';

interface MediaLibraryInspectorProps {
  assetId: string | null;
  onSelect?: (asset: Asset) => void;
}

export function MediaLibraryInspector({ assetId, onSelect }: MediaLibraryInspectorProps) {
  const { assets, renameAsset, removeAsset } = useAssets();
  const [isRenaming, setIsRenaming] = useState(false);
  const [newName, setNewName] = useState('');

  const asset = assets.find(a => a.id === assetId);

  if (!asset) {
    return <div className="media-library-inspector--empty">选择素材查看详情</div>;
  }

  const handleRename = async () => {
    try {
      await renameAsset(asset.id, newName);
      setIsRenaming(false);
      MessagePlugin.success('重命名成功');
    } catch (error) {
      MessagePlugin.error('重命名失败');
    }
  };

  const handleDelete = async () => {
    // 显示确认对话框
    const confirmed = await showDeleteConfirmation();
    if (confirmed) {
      await removeAsset(asset.id);
      MessagePlugin.success('删除成功');
    }
  };

  return (
    <div className="media-library-inspector">
      {/* 预览 */}
      <div className="media-library-inspector__preview">
        {asset.type === 'IMAGE' ? (
          <img src={asset.url} alt={asset.name} />
        ) : (
          <video src={asset.url} controls />
        )}
      </div>

      {/* 名称编辑 */}
      <div className="media-library-inspector__name">
        {isRenaming ? (
          <Input
            value={newName}
            onChange={setNewName}
            onBlur={handleRename}
            autoFocus
          />
        ) : (
          <div>
            <span>{asset.name}</span>
            <Button
              size="small"
              variant="text"
              icon={<Edit2 />}
              onClick={() => {
                setNewName(asset.name);
                setIsRenaming(true);
              }}
            />
          </div>
        )}
      </div>

      {/* 元数据 */}
      <div className="media-library-inspector__metadata">
        <div>类型: {asset.type}</div>
        <div>来源: {asset.source}</div>
        <div>创建时间: {new Date(asset.createdAt).toLocaleString()}</div>
      </div>

      {/* 操作按钮 */}
      <div className="media-library-inspector__actions">
        {onSelect && (
          <Button
            theme="primary"
            block
            icon={<CheckCircle />}
            onClick={() => onSelect(asset)}
          >
            使用到画板
          </Button>
        )}
        <Button
          variant="outline"
          block
          icon={<Download />}
          onClick={() => downloadAsset(asset)}
        >
          下载
        </Button>
        <Button
          theme="danger"
          variant="outline"
          block
          icon={<Trash2 />}
          onClick={handleDelete}
        >
          删除
        </Button>
      </div>
    </div>
  );
}

验收标准:

  • 使用TDesign组件
  • 删除前显示确认对话框
  • 文件 < 200 行

Phase 5: 集成AI生成对话框 (P1)

目标: 在AI生成对话框中添加素材库选择功能

5.1 修改AI生图对话框

文件: packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx (修改)

import { MediaLibraryModal } from '../media-library/MediaLibraryModal';

function AIImageGeneration() {
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
  const [referenceImage, setReferenceImage] = useState<Asset | null>(null);

  return (
    <div>
      {/* 现有代码 */}

      {/* 新增:参考图选择器 */}
      <div className="reference-image-selector">
        <Button onClick={() => setShowMediaLibrary(true)}>
          从素材库选择
        </Button>
        <Button onClick={() => /* 现有的本地选择逻辑 */}>
          从本地选择
        </Button>
      </div>

      {/* 素材库弹窗 */}
      <MediaLibraryModal
        isOpen={showMediaLibrary}
        onClose={() => setShowMediaLibrary(false)}
        onSelect={(asset) => {
          setReferenceImage(asset);
          setShowMediaLibrary(false);
        }}
        filterType="IMAGE"  // 只显示图片
      />
    </div>
  );
}

验收标准:

  • 不破坏现有功能
  • 素材库只显示图片
  • 选择后正确应用参考图

5.2 类似修改AI生视频对话框

文件: packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx (修改)

Phase 6: 自动保存AI生成内容 (P1)

目标: AI生成完成后自动添加到素材库

6.1 创建集成服务

文件: packages/drawnix/src/services/asset-integration-service.ts

import { taskQueueService } from './task-queue-service';
import { assetStorageService } from './asset-storage-service';

export function initializeAssetIntegration() {
  // 订阅任务队列
  taskQueueService.tasks$.subscribe(async (tasks) => {
    for (const task of tasks) {
      if (task.status === 'completed' && task.resultUrl && !task.savedToLibrary) {
        try {
          // 下载结果
          const response = await fetch(task.resultUrl);
          const blob = await response.blob();

          // 确定类型
          const assetType = task.type === 'image-generation'
            ? AssetType.IMAGE
            : AssetType.VIDEO;

          // 生成名称
          const name = generateAssetName(task.prompt, assetType);

          // 保存到素材库
          await assetStorageService.addAsset({
            type: assetType,
            source: AssetSource.AI_GENERATED,
            name,
            blob,
            mimeType: blob.type,
            prompt: task.prompt,
            modelName: task.modelName
          });

          // 标记已保存
          taskQueueService.markAsSaved(task.id);
        } catch (error) {
          console.error('Failed to save AI generated asset:', error);
          // 不阻塞任务队列
        }
      }
    }
  });
}

function generateAssetName(prompt: string | undefined, type: AssetType): string {
  if (prompt && prompt.length > 0) {
    const truncated = prompt.substring(0, 20);
    return truncated.length < prompt.length ? `${truncated}...` : truncated;
  }
  const timestamp = new Date().toISOString();
  return `AI${type}-${timestamp}`;
}

6.2 在应用启动时初始化

文件: packages/drawnix/src/drawnix.tsx (修改)

import { initializeAssetIntegration } from './services/asset-integration-service';

useEffect(() => {
  // 初始化素材集成
  initializeAssetIntegration();
}, []);

验收标准:

  • 生成成功后自动添加
  • 失败不影响任务队列
  • 避免重复添加

测试策略

Unit Tests

# 运行单元测试
npm test -- asset-storage-service.spec.ts
npm test -- asset-utils.spec.ts

Component Tests

# 运行组件测试
npm test -- MediaLibraryModal.spec.tsx
npm test -- AssetGridItem.spec.tsx

E2E Tests

# 运行E2E测试
npx playwright test media-library

调试技巧

1. 查看IndexedDB数据

// 在浏览器控制台
const store = localforage.createInstance({ name: 'aitu-assets' });
const keys = await store.keys();
console.log('Stored assets:', keys);

const asset = await store.getItem(keys[0]);
console.log('First asset:', asset);

2. React DevTools

使用React DevTools查看

  • AssetContext的state
  • 组件重渲染情况
  • Props传递链

3. Network Tab

监控AI生成任务的网络请求确保结果正确下载。

常见问题

Q: 如何清除所有素材?

await assetStorageService.clearAll();

Q: 如何处理存储空间不足?

try {
  await assetStorageService.addAsset({...});
} catch (error) {
  if (error instanceof QuotaExceededError) {
    // 提示用户删除旧素材
    MessagePlugin.error('存储空间不足,请删除一些旧素材');
  }
}

Q: 如何优化大量素材的加载?

  • 使用虚拟滚动react-window
  • 懒加载图片IntersectionObserver
  • 分页加载(如果素材 > 500

部署检查清单

  • 所有单元测试通过
  • 所有组件测试通过
  • E2E测试通过
  • TypeScript类型检查通过
  • 所有文件 < 500行
  • 使用TDesign组件light主题
  • BEM命名规范
  • 性能指标达标2s加载500ms筛选
  • 存储配额监控工作正常
  • AI生成内容自动保存
  • 删除确认对话框显示
  • 响应式布局在移动端正常

下一步

完成实现后:

  1. 运行 /speckit.tasks 生成详细任务分解
  2. 创建pull request
  3. 代码审查
  4. 部署到预发布环境
  5. 用户验收测试

快速开始指南完成日期: 2025-12-11 准备进入: Phase 2 - Task Generation (运行 /speckit.tasks)