美业小程序架构设计
技术架构概览
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,原因:
- 提供清晰的 API 接口
- 集中管理素材逻辑
- 便于单元测试
- 支持数据持久化
2. 为什么采用本地优先策略?
设计理念: Local First
优势:
- 离线可用:用户可以先拍摄,后上传
- 性能更好:本地读取速度快
- 容错性强:网络异常不影响拍摄
- 用户体验:即时反馈
实现:
javascript
// 拍摄后立即保存到本地
await segmentManager.addSegment(...);
// 提交生成时再上传
const uploadedUrl = await uploadFile(localPath);3. 为什么使用组件化弹窗?
问题: 场景回顾功能复杂,代码量大
方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 页面内实现 | 无组件通信开销 | 代码臃肿,难以维护 |
| 独立组件 | 职责清晰,可复用 | 需要 props/events 通信 |
| 全局弹窗管理 | 统一管理 | 过度设计 |
决策: 使用独立组件,原因:
- 职责单一,易于维护
- 可在其他页面复用
- 便于单独测试
- 符合组件化设计原则
性能优化策略
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 };
};