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

878 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 克隆并安装依赖
```bash
cd /Users/gongchengtu/code/github/aitu
git checkout 009-media-library
# 安装依赖(如果尚未安装)
npm install
```
### 2. 验证开发服务器
```bash
npm start # 启动开发服务器在 localhost:7200
```
### 3. 运行测试
```bash
npm test # 运行所有测试
```
## 实现顺序(按优先级)
### Phase 1: 核心数据层 (P1)
**目标**: 实现存储服务和数据模型
#### 1.1 创建类型定义
**文件**: `packages/drawnix/src/types/asset.types.ts`
```typescript
// 参考 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`
```typescript
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`
```typescript
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`
```typescript
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` (修改)
```typescript
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`
```typescript
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`
```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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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` (修改)
```typescript
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`
```typescript
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` (修改)
```typescript
import { initializeAssetIntegration } from './services/asset-integration-service';
useEffect(() => {
// 初始化素材集成
initializeAssetIntegration();
}, []);
```
**验收标准**:
- 生成成功后自动添加
- 失败不影响任务队列
- 避免重复添加
## 测试策略
### Unit Tests
```bash
# 运行单元测试
npm test -- asset-storage-service.spec.ts
npm test -- asset-utils.spec.ts
```
### Component Tests
```bash
# 运行组件测试
npm test -- MediaLibraryModal.spec.tsx
npm test -- AssetGridItem.spec.tsx
```
### E2E Tests
```bash
# 运行E2E测试
npx playwright test media-library
```
## 调试技巧
### 1. 查看IndexedDB数据
```javascript
// 在浏览器控制台
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: 如何清除所有素材?
```typescript
await assetStorageService.clearAll();
```
### Q: 如何处理存储空间不足?
```typescript
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`)