Skip to content

美业小程序架构设计

技术架构概览

mermaid
graph TB
    subgraph 小程序端
        A[用户界面] --> B[页面层]
        B --> C[组件层]
        C --> D[工具层]
    end
    
    subgraph 数据管理
        E[本地存储] --> F[SegmentManager]
        F --> G[素材管理]
        F --> H[状态管理]
    end
    
    subgraph 后端服务
        I[RESTful API] --> J[文件上传]
        I --> K[任务管理]
        I --> L[生成服务]
        M[WebSocket] --> N[进度推送]
    end
    
    B --> E
    B --> I
    B --> M
    D --> E
    D --> I

核心模块

1. 页面层 (Pages)

task-detail 场景拍摄列表页

职责:

  • 展示流水线信息
  • 管理5个场景的拍摄状态
  • 提供场景回顾入口
  • 选择生成模式并提交任务

关键状态:

javascript
const expandedScene = ref(-1);           // 当前展开的场景
const currentScene = ref(0);             // 当前选中的场景
const showSceneReviewPopup = ref(false); // 场景回顾弹窗
const segmentManager = ref(null);        // 素材管理器
const pipelineInfo = computed(...);      // 流水线配置
const overallProgress = computed(...);   // 整体进度

数据流:

用户选择流水线

加载流水线配置 (BEAUTY_PIPELINES)

初始化 SegmentManager

从本地存储加载素材数据

计算各场景完成状态

展示场景列表

beauty-camera 拍摄页面

职责:

  • 相机拍摄
  • 提词器显示
  • 视频录制
  • 素材保存

2. 组件层 (Components)

scene-review-popup 场景回顾弹窗

接口设计:

typescript
interface SceneReviewPopupProps {
  visible: boolean;
  scenes: Scene[];
  sceneIndex: number;
  segmentManager: SegmentManager;
  pipelineType: string;
}

interface SceneReviewPopupEvents {
  close: () => void;
  retake: (sceneIndex: number) => void;
  'script-update': (data: {
    sceneIndex: number;
    script: string;
  }) => void;
}

内部逻辑:

javascript
// 场景切换逻辑
const switchScene = (direction: 'prev' | 'next') => {
  let newIndex = currentIndex.value + (direction === 'next' ? 1 : -1);
  
  // 智能跳过未拍摄场景
  while (newIndex >= 0 && newIndex < scenes.length) {
    const segment = segmentManager.getSegment(newIndex);
    if (segment) {
      currentIndex.value = newIndex;
      return;
    }
    newIndex += (direction === 'next' ? 1 : -1);
  }
  
  // 已到边界
  uni.showToast({ title: '已是最后一个场景', icon: 'none' });
};

script-edit-modal 文案编辑弹窗

功能实现:

javascript
// 字数统计
const charCount = computed(() => localScript.value.length);
const isOverLimit = computed(() => charCount.value > 500);

// 保存校验
const handleSave = () => {
  if (!localScript.value.trim()) {
    uni.showToast({ title: '文案不能为空', icon: 'none' });
    return;
  }
  
  emit('save', {
    sceneIndex: props.sceneIndex,
    script: localScript.value
  });
  
  emit('close');
};

3. 数据管理层

SegmentManager 素材管理器

核心功能:

javascript
class SegmentManager {
  constructor(totalScenes: number, projectId: string) {
    this.segments = [];
    this.photos = {};
    this.totalScenes = totalScenes;
    this.storageKey = `beauty_segments_${projectId}`;
    this.loadFromStorage();
  }
  
  // 添加视频片段
  async addSegment(videoPath, sceneIndex, duration, sceneInfo, voiceover) {
    const segment = {
      sceneIndex,
      videoPath,
      duration,
      sceneInfo,
      voiceover,
      recordedAt: new Date().toISOString()
    };
    
    this.segments[sceneIndex] = segment;
    await this.saveToStorage();
  }
  
  // 添加照片
  async addPhoto(sceneIndex, photoPath, metadata = {}) {
    if (!this.photos[sceneIndex]) {
      this.photos[sceneIndex] = [];
    }
    
    this.photos[sceneIndex].push({
      path: photoPath,
      url: photoPath,
      timestamp: Date.now(),
      ...metadata
    });
    
    await this.saveToStorage();
  }
  
  // 获取整体进度
  getOverallProgress() {
    const videoCount = this.segments.filter(s => s).length;
    const photoCount = Object.values(this.photos)
      .reduce((sum, photos) => sum + photos.length, 0);
    
    const completedScenes = this.segments.filter((seg, idx) => {
      return seg && this.photos[idx]?.length >= 2;
    }).length;
    
    return {
      videoCount,
      photoCount,
      completedScenes,
      totalScenes: this.totalScenes,
      requiredVideos: this.totalScenes,
      requiredPhotos: this.totalScenes * 2
    };
  }
  
  // 检查生成模式是否可用
  canGenerateMode(mode) {
    const progress = this.getOverallProgress();
    
    const modeRequirements = {
      photo_i2v: { minScenes: 5, requiresPhotos: true },
      video_basic: { minScenes: 3, requiresPhotos: false },
      graphic_only: { minScenes: 1, requiresPhotos: true }
    };
    
    const req = modeRequirements[mode];
    if (!req) return { canGenerate: false };
    
    const hasEnoughScenes = progress.completedScenes >= req.minScenes;
    const hasPhotos = req.requiresPhotos ? progress.photoCount >= req.minScenes * 2 : true;
    
    return {
      canGenerate: hasEnoughScenes && hasPhotos,
      reason: !hasEnoughScenes ? `需要至少${req.minScenes}个完整场景` : '素材不足'
    };
  }
}

本地存储策略

javascript
// 存储键命名
const STORAGE_KEYS = {
  segments: (projectId) => `beauty_segments_${projectId}`,
  storeInfo: 'beauty_store_info',
  projectId: 'beauty_project_id',
  taskResult: (taskId) => `task_result_${taskId}`
};

// 数据持久化
const saveToStorage = async (key, data) => {
  try {
    uni.setStorageSync(key, JSON.stringify(data));
  } catch (error) {
    console.error('Storage error:', error);
    uni.showToast({ title: '保存失败', icon: 'none' });
  }
};

// 数据加载
const loadFromStorage = (key, defaultValue = null) => {
  try {
    const data = uni.getStorageSync(key);
    return data ? JSON.parse(data) : defaultValue;
  } catch (error) {
    console.error('Load error:', error);
    return defaultValue;
  }
};

4. 网络通信层

RESTful API

javascript
// API 基础配置
const getApiBase = () => {
  return uni.getStorageSync('api_base') || 'http://localhost:5000';
};

// 文件上传
const uploadFile = async (filePath, type = 'beauty') => {
  const token = uni.getStorageSync('token');
  const apiBase = getApiBase();
  
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: `${apiBase}/api/files/upload-${type}`,
      filePath,
      name: 'file',
      header: {
        'Authorization': `Bearer ${token}`
      },
      success: (res) => {
        const data = JSON.parse(res.data);
        if (data.success) {
          resolve(data.file_url || data.url);
        } else {
          reject(new Error(data.message));
        }
      },
      fail: reject
    });
  });
};

// 启动生成任务
const startGeneration = async (payload) => {
  const token = uni.getStorageSync('token');
  const apiBase = getApiBase();
  
  const res = await uni.request({
    url: `${apiBase}/api/beauty/start-mixed-generation`,
    method: 'POST',
    header: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    data: payload
  });
  
  return res.data;
};

WebSocket 进度推送

javascript
// WebSocket 连接
const connectWebSocket = (taskId) => {
  const wsUrl = getApiBase().replace('http', 'ws');
  const socket = uni.connectSocket({
    url: `${wsUrl}/ws/progress/${taskId}`
  });
  
  socket.onMessage((res) => {
    const data = JSON.parse(res.data);
    
    if (data.type === 'progress') {
      updateProgress(data.progress);
    } else if (data.type === 'complete') {
      handleComplete(data.result);
    } else if (data.type === 'error') {
      handleError(data.error);
    }
  });
  
  return socket;
};

数据流图

拍摄流程

mermaid
sequenceDiagram
    participant U as 用户
    participant P as 拍摄页面
    participant S as SegmentManager
    participant L as 本地存储
    participant B as 后端API
    
    U->>P: 点击"开始录制"
    P->>P: 打开相机
    U->>P: 录制视频
    P->>S: addSegment(videoPath, sceneIndex)
    S->>L: saveToStorage()
    P->>B: uploadFile(videoPath)
    B-->>P: 返回视频URL
    P->>S: 更新segment.url
    S->>L: saveToStorage()
    P->>U: 显示完成状态

生成流程

mermaid
sequenceDiagram
    participant U as 用户
    participant T as 场景列表页
    participant S as SegmentManager
    participant B as 后端API
    participant W as WebSocket
    participant G as 生成进度页
    
    U->>T: 选择生成模式
    T->>S: exportAssets()
    S-->>T: 返回素材数据
    T->>B: 上传所有素材
    B-->>T: 返回素材URL
    T->>B: startGeneration(payload)
    B-->>T: 返回taskId
    T->>S: markAsSubmitted(taskId)
    T->>G: 跳转到进度页
    G->>W: 连接WebSocket(taskId)
    W-->>G: 推送进度更新
    W-->>G: 推送完成结果
    G->>U: 显示成品视频

关键技术决策

1. 为什么使用 SegmentManager?

问题: 需要管理多个场景的视频和照片素材

方案对比:

方案优点缺点
Vuex/Pinia统一状态管理小程序不支持
直接使用 Storage简单直接缺乏封装,难以维护
SegmentManager封装完善,易于测试需要额外维护类

决策: 使用 SegmentManager,原因:

  1. 提供清晰的 API 接口
  2. 集中管理素材逻辑
  3. 便于单元测试
  4. 支持数据持久化

2. 为什么采用本地优先策略?

设计理念: Local First

优势:

  • 离线可用:用户可以先拍摄,后上传
  • 性能更好:本地读取速度快
  • 容错性强:网络异常不影响拍摄
  • 用户体验:即时反馈

实现:

javascript
// 拍摄后立即保存到本地
await segmentManager.addSegment(...);

// 提交生成时再上传
const uploadedUrl = await uploadFile(localPath);

3. 为什么使用组件化弹窗?

问题: 场景回顾功能复杂,代码量大

方案对比:

方案优点缺点
页面内实现无组件通信开销代码臃肿,难以维护
独立组件职责清晰,可复用需要 props/events 通信
全局弹窗管理统一管理过度设计

决策: 使用独立组件,原因:

  1. 职责单一,易于维护
  2. 可在其他页面复用
  3. 便于单独测试
  4. 符合组件化设计原则

性能优化策略

1. 渲染优化

javascript
// 使用 v-if 而非 v-show
<view v-if="expandedScene >= 0">
  <!-- 只渲染展开的场景 -->
</view>

// 列表项懒加载
<scroll-view :scroll-into-view="`scene-${currentScene}`">
  <!-- 自动滚动到当前场景 -->
</scroll-view>

2. 数据优化

javascript
// 使用计算属性缓存
const overallProgress = computed(() => {
  // 只在 segmentManager 变化时重新计算
  return segmentManager.value?.getOverallProgress();
});

// 防抖处理
const debouncedSave = debounce(() => {
  segmentManager.value.saveToStorage();
}, 500);

3. 网络优化

javascript
// 批量上传
const uploadAll = async (files) => {
  return Promise.all(files.map(uploadFile));
};

// 断点续传(TODO)
const resumableUpload = async (filePath, chunkSize = 1024 * 1024) => {
  // 实现分片上传
};

错误处理

1. 网络错误

javascript
try {
  const result = await uploadFile(filePath);
} catch (error) {
  if (error.message.includes('timeout')) {
    uni.showModal({
      title: '上传超时',
      content: '网络不稳定,是否重试?',
      success: (res) => {
        if (res.confirm) {
          uploadFile(filePath);
        }
      }
    });
  }
}

2. 存储错误

javascript
// 存储容量检查
const checkStorageSpace = () => {
  try {
    const info = uni.getStorageInfoSync();
    const usedMB = info.currentSize / 1024;
    const limitMB = info.limitSize / 1024;
    
    if (usedMB / limitMB > 0.9) {
      uni.showModal({
        title: '存储空间不足',
        content: '请清理缓存后继续使用'
      });
      return false;
    }
    return true;
  } catch (error) {
    console.error('Storage check error:', error);
    return true; // 检查失败时允许继续
  }
};

3. 素材丢失

javascript
// 检查文件是否存在
const checkFileExists = async (filePath) => {
  if (filePath.startsWith('http')) {
    return true; // 远程文件假定存在
  }
  
  return new Promise((resolve) => {
    const fs = uni.getFileSystemManager();
    fs.access({
      path: filePath,
      success: () => resolve(true),
      fail: () => resolve(false)
    });
  });
};

// 提交前检查所有素材
const validateAssets = async (assets) => {
  const invalid = [];
  
  for (const asset of assets) {
    const exists = await checkFileExists(asset.path);
    if (!exists) {
      invalid.push(asset);
    }
  }
  
  if (invalid.length > 0) {
    uni.showModal({
      title: '部分素材已失效',
      content: `${invalid.length}个素材需要重新拍摄`
    });
    return false;
  }
  
  return true;
};

安全考虑

1. Token 管理

javascript
// Token 存储
uni.setStorageSync('token', tokenValue);

// Token 使用
const getAuthHeader = () => {
  const token = uni.getStorageSync('token');
  return token ? `Bearer ${token}` : '';
};

// Token 过期处理
const handleTokenExpired = () => {
  uni.removeStorageSync('token');
  uni.reLaunch({
    url: '/pages/login/index'
  });
};

2. 输入校验

javascript
// 口播文案校验
const validateScript = (script) => {
  if (!script || !script.trim()) {
    return { valid: false, message: '文案不能为空' };
  }
  
  if (script.length > 500) {
    return { valid: false, message: '文案不能超过500字' };
  }
  
  // XSS 过滤
  const sanitized = script.replace(/<script/gi, '');
  
  return { valid: true, sanitized };
};

相关文档

© 2024-2025 趣美丽 QuMeiLi · Powered by 刻流星引擎 KeLiuXing