1460 lines
32 KiB
Markdown
1460 lines
32 KiB
Markdown
# iMeeting Web Client - 技术设计文档
|
||
|
||
## 一、项目概述
|
||
|
||
### 1.1 项目定位
|
||
iMeeting Web Client 是一个基于 React 的纯前端轻量级会议录音客户端,专注于快速录音和会议管理功能。
|
||
|
||
### 1.2 核心特性
|
||
- 🎤 **一键录音**:点击即录,实时流式上传
|
||
- 📚 **会议列表**:快速查看历史会议,生成分享二维码
|
||
- 📱 **响应式设计**:支持桌面端、平板、移动端
|
||
- 🚀 **纯前端实现**:无需额外服务器,直接调用现有API
|
||
|
||
---
|
||
|
||
## 二、界面设计分析
|
||
|
||
### 2.1 主界面布局(参考 design.png)
|
||
|
||
采用 2x2 网格卡片布局:
|
||
|
||
```
|
||
┌─────────────┬─────────────┐
|
||
│ 麦克风 │ AI │
|
||
│ (橙色) │ (灰色) │
|
||
│ [激活] │ [预留] │
|
||
├─────────────┼─────────────┤
|
||
│ 大脑 │ 知识库 │
|
||
│ (灰色) │ (橙色) │
|
||
│ [预留] │ [激活] │
|
||
└─────────────┴─────────────┘
|
||
```
|
||
|
||
**功能状态:**
|
||
- ✅ **麦克风(左上,橙色)**:一键录音功能 - **已激活**
|
||
- ⏸ **AI(右上,灰色)**:AI分析功能 - 预留
|
||
- ⏸ **大脑(左下,灰色)**:智能功能 - 预留
|
||
- ✅ **知识库(右下,橙色)**:会议列表 - **已激活**
|
||
|
||
### 2.2 页面结构
|
||
```
|
||
/ - 主页(4个功能卡片)
|
||
├── /record - 录音页面(点击麦克风进入)
|
||
└── /meetings - 会议列表页(点击知识库进入)
|
||
└── /:id - 会议详情(简介+二维码)
|
||
```
|
||
|
||
### 2.3 响应式断点
|
||
```css
|
||
/* 移动端 */
|
||
@media (max-width: 768px) {
|
||
/* 单列布局,卡片堆叠 */
|
||
}
|
||
|
||
/* 平板 */
|
||
@media (min-width: 768px) and (max-width: 1024px) {
|
||
/* 2x2布局,卡片适中 */
|
||
}
|
||
|
||
/* 桌面 */
|
||
@media (min-width: 1024px) {
|
||
/* 2x2布局,卡片标准 */
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、技术架构
|
||
|
||
### 3.1 技术栈选型
|
||
|
||
#### 核心框架
|
||
- **React 18**:UI框架
|
||
- **React Router v6**:路由管理
|
||
- **Vite**:构建工具(快速、轻量)
|
||
|
||
#### 样式方案
|
||
- **CSS Modules**:模块化样式
|
||
- **响应式布局**:CSS Grid + Flexbox
|
||
|
||
#### 状态管理
|
||
- **React Context API**:全局状态(用户、录音状态)
|
||
- **useState/useReducer**:局部状态
|
||
|
||
#### 核心库
|
||
```json
|
||
{
|
||
"react": "^18.2.0",
|
||
"react-router-dom": "^6.20.0",
|
||
"axios": "^1.6.0",
|
||
"qrcode.react": "^3.1.0",
|
||
"lucide-react": "^0.292.0"
|
||
}
|
||
```
|
||
|
||
### 3.2 项目结构
|
||
```
|
||
client/
|
||
├── public/
|
||
│ └── index.html
|
||
├── src/
|
||
│ ├── components/ # 公共组件
|
||
│ │ ├── Layout/
|
||
│ │ │ └── MainLayout.jsx
|
||
│ │ ├── FeatureCard/ # 功能卡片
|
||
│ │ │ ├── FeatureCard.jsx
|
||
│ │ │ └── FeatureCard.module.css
|
||
│ │ ├── RecordButton/ # 录音按钮
|
||
│ │ └── QRCodeModal/ # 二维码弹窗
|
||
│ ├── pages/
|
||
│ │ ├── Home/ # 主页(2x2卡片)
|
||
│ │ │ ├── Home.jsx
|
||
│ │ │ └── Home.module.css
|
||
│ │ ├── Record/ # 录音页
|
||
│ │ │ ├── Record.jsx
|
||
│ │ │ └── Record.module.css
|
||
│ │ └── Meetings/ # 会议列表
|
||
│ │ ├── MeetingList.jsx
|
||
│ │ ├── MeetingDetail.jsx
|
||
│ │ └── Meetings.module.css
|
||
│ ├── hooks/ # 自定义Hooks
|
||
│ │ ├── useAudioRecorder.js
|
||
│ │ ├── useStreamUpload.js
|
||
│ │ └── useAuth.js
|
||
│ ├── services/ # API服务
|
||
│ │ ├── api.js # Axios封装
|
||
│ │ ├── auth.js
|
||
│ │ ├── meeting.js
|
||
│ │ └── upload.js
|
||
│ ├── utils/
|
||
│ │ ├── audio.js # 音频工具
|
||
│ │ └── format.js
|
||
│ ├── styles/
|
||
│ │ └── global.css
|
||
│ ├── App.jsx
|
||
│ └── main.jsx
|
||
├── package.json
|
||
├── vite.config.js
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
## 四、核心功能实现
|
||
|
||
### 4.1 一键录音功能
|
||
|
||
#### 4.1.1 录音流程
|
||
```
|
||
用户点击 → 请求麦克风权限 → 开始录音 →
|
||
分片收集 → 实时上传 → 停止录音 →
|
||
创建会议记录 → 跳转到会议详情
|
||
```
|
||
|
||
#### 4.1.2 技术实现
|
||
|
||
**(1)使用 MediaRecorder API**
|
||
```javascript
|
||
// hooks/useAudioRecorder.js
|
||
import { useState, useRef, useCallback } from 'react';
|
||
|
||
export const useAudioRecorder = () => {
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [duration, setDuration] = useState(0);
|
||
const mediaRecorderRef = useRef(null);
|
||
const streamRef = useRef(null);
|
||
const chunksRef = useRef([]);
|
||
|
||
const startRecording = useCallback(async () => {
|
||
try {
|
||
// 请求麦克风权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
echoCancellation: true, // 回声消除
|
||
noiseSuppression: true, // 降噪
|
||
sampleRate: 44100 // 采样率
|
||
}
|
||
});
|
||
|
||
streamRef.current = stream;
|
||
|
||
// 选择合适的MIME类型
|
||
const mimeType = getSupportedMimeType();
|
||
|
||
const mediaRecorder = new MediaRecorder(stream, {
|
||
mimeType,
|
||
audioBitsPerSecond: 128000
|
||
});
|
||
|
||
// 每秒收集一次数据(用于流式上传)
|
||
mediaRecorder.start(1000);
|
||
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) {
|
||
chunksRef.current.push(event.data);
|
||
// 触发流式上传
|
||
onChunkAvailable(event.data);
|
||
}
|
||
};
|
||
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
setIsRecording(true);
|
||
|
||
} catch (error) {
|
||
handleRecordingError(error);
|
||
}
|
||
}, []);
|
||
|
||
const stopRecording = useCallback(() => {
|
||
if (mediaRecorderRef.current) {
|
||
mediaRecorderRef.current.stop();
|
||
streamRef.current?.getTracks().forEach(track => track.stop());
|
||
setIsRecording(false);
|
||
}
|
||
}, []);
|
||
|
||
return { isRecording, duration, startRecording, stopRecording };
|
||
};
|
||
|
||
// 获取浏览器支持的MIME类型
|
||
const getSupportedMimeType = () => {
|
||
const types = [
|
||
'audio/webm;codecs=opus',
|
||
'audio/webm',
|
||
'audio/ogg;codecs=opus',
|
||
'audio/mp4'
|
||
];
|
||
return types.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||
};
|
||
```
|
||
|
||
**(2)流式上传实现**
|
||
|
||
##### 方案一:分片上传(推荐)
|
||
|
||
**优点:**
|
||
- ✅ HTTP协议,简单稳定
|
||
- ✅ 可断点续传
|
||
- ✅ 兼容现有后端
|
||
|
||
**实现:**
|
||
```javascript
|
||
// hooks/useStreamUpload.js
|
||
import { useState, useCallback } from 'react';
|
||
import { uploadAudioChunk, completeUpload } from '../services/upload';
|
||
|
||
export const useStreamUpload = () => {
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [sessionId, setSessionId] = useState(null);
|
||
const chunkIndexRef = useRef(0);
|
||
|
||
const startSession = useCallback(async (meetingId) => {
|
||
// 初始化上传会话
|
||
const session = await initUploadSession(meetingId);
|
||
setSessionId(session.session_id);
|
||
return session;
|
||
}, []);
|
||
|
||
const uploadChunk = useCallback(async (chunk) => {
|
||
if (!sessionId) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('chunk', chunk);
|
||
formData.append('chunk_index', chunkIndexRef.current);
|
||
formData.append('session_id', sessionId);
|
||
|
||
try {
|
||
await uploadAudioChunk(formData);
|
||
chunkIndexRef.current++;
|
||
setUploadProgress(prev => prev + 1);
|
||
} catch (error) {
|
||
console.error('Upload chunk failed:', error);
|
||
// 可以实现重试逻辑
|
||
}
|
||
}, [sessionId]);
|
||
|
||
const finishUpload = useCallback(async (meetingId) => {
|
||
if (!sessionId) return;
|
||
|
||
return await completeUpload({
|
||
session_id: sessionId,
|
||
meeting_id: meetingId,
|
||
total_chunks: chunkIndexRef.current
|
||
});
|
||
}, [sessionId]);
|
||
|
||
return { startSession, uploadChunk, finishUpload, uploadProgress };
|
||
};
|
||
```
|
||
|
||
**后端需要新增的接口:**
|
||
```javascript
|
||
// 需要后端提供这些接口
|
||
POST /api/audio/upload-init // 初始化上传会话
|
||
POST /api/audio/upload-chunk // 上传分片
|
||
POST /api/audio/upload-complete // 完成上传,合并分片
|
||
```
|
||
|
||
##### 方案二:简化方案(先录完再传)
|
||
|
||
如果后端暂时不支持分片,可以先实现简化版本:
|
||
|
||
```javascript
|
||
const stopAndUpload = useCallback(async () => {
|
||
mediaRecorder.stop();
|
||
|
||
// 等待所有数据收集完毕
|
||
mediaRecorder.onstop = async () => {
|
||
const audioBlob = new Blob(chunksRef.current, { type: mimeType });
|
||
|
||
// 一次性上传完整音频
|
||
const formData = new FormData();
|
||
formData.append('audio_file', audioBlob, 'recording.webm');
|
||
formData.append('meeting_id', meetingId);
|
||
|
||
await uploadAudio(formData);
|
||
};
|
||
}, []);
|
||
```
|
||
|
||
**推荐:先实现方案二(简单快速),后续优化为方案一**
|
||
|
||
#### 4.1.3 录音界面设计
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ 正在录音... │
|
||
│ │
|
||
│ ⏺ [动画波形] │
|
||
│ │
|
||
│ 00:03:24 │
|
||
│ │
|
||
│ ━━━━━━━━━━━━━━━━━━━━ │
|
||
│ │
|
||
│ [暂停] [停止并保存] │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
**关键元素:**
|
||
- 录音状态指示(红点闪烁)
|
||
- 实时时长显示
|
||
- 简单的音量可视化(可选)
|
||
- 暂停/继续按钮
|
||
- 停止按钮
|
||
|
||
**错误处理:**
|
||
```javascript
|
||
const handleRecordingError = (error) => {
|
||
switch(error.name) {
|
||
case 'NotAllowedError':
|
||
showError('需要麦克风权限,请在浏览器设置中允许');
|
||
break;
|
||
case 'NotFoundError':
|
||
showError('未检测到麦克风设备');
|
||
break;
|
||
case 'NotReadableError':
|
||
showError('麦克风被其他应用占用');
|
||
break;
|
||
default:
|
||
showError('录音失败,请重试');
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### 4.2 会议列表功能
|
||
|
||
#### 4.2.1 列表页实现
|
||
|
||
```javascript
|
||
// pages/Meetings/MeetingList.jsx
|
||
import { useState, useEffect } from 'react';
|
||
import { getMeetings } from '../../services/meeting';
|
||
import QRCodeModal from '../../components/QRCodeModal';
|
||
|
||
const MeetingList = () => {
|
||
const [meetings, setMeetings] = useState([]);
|
||
const [selectedMeeting, setSelectedMeeting] = useState(null);
|
||
const [showQR, setShowQR] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadMeetings();
|
||
}, []);
|
||
|
||
const loadMeetings = async () => {
|
||
const response = await getMeetings({
|
||
user_id: currentUser.user_id,
|
||
page: 1,
|
||
page_size: 50
|
||
});
|
||
setMeetings(response.data.meetings);
|
||
};
|
||
|
||
const handleShowQR = (meeting) => {
|
||
setSelectedMeeting(meeting);
|
||
setShowQR(true);
|
||
};
|
||
|
||
return (
|
||
<div className="meeting-list">
|
||
{meetings.map(meeting => (
|
||
<div key={meeting.meeting_id} className="meeting-item">
|
||
<h3>{meeting.title}</h3>
|
||
<p className="time">{formatDateTime(meeting.meeting_time)}</p>
|
||
<p className="summary">{meeting.summary?.substring(0, 100)}...</p>
|
||
<button onClick={() => handleShowQR(meeting)}>
|
||
查看二维码
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{showQR && (
|
||
<QRCodeModal
|
||
meeting={selectedMeeting}
|
||
onClose={() => setShowQR(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
#### 4.2.2 二维码组件
|
||
|
||
```javascript
|
||
// components/QRCodeModal/QRCodeModal.jsx
|
||
import { QRCodeSVG } from 'qrcode.react';
|
||
|
||
const QRCodeModal = ({ meeting, onClose }) => {
|
||
const qrUrl = `${window.location.origin}/meetings/preview/${meeting.meeting_id}`;
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<h2>{meeting.title}</h2>
|
||
|
||
<div className="qr-code-container">
|
||
<QRCodeSVG
|
||
value={qrUrl}
|
||
size={256}
|
||
level="H"
|
||
includeMargin={true}
|
||
/>
|
||
</div>
|
||
|
||
<p className="hint">扫描二维码查看会议详情</p>
|
||
|
||
<div className="meeting-info">
|
||
<p><strong>会议时间:</strong>{formatDateTime(meeting.meeting_time)}</p>
|
||
<p><strong>创建人:</strong>{meeting.creator_username}</p>
|
||
<p><strong>参会人数:</strong>{meeting.attendees?.length || 0}</p>
|
||
</div>
|
||
|
||
<button onClick={onClose}>关闭</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
**二维码跳转:**
|
||
- URL: `/meetings/preview/{meetingId}`
|
||
- 这个页面已经在后端实现(无需登录即可查看)
|
||
|
||
---
|
||
|
||
## 五、API接口清单
|
||
|
||
### 5.1 复用现有接口
|
||
|
||
#### 用户认证
|
||
```javascript
|
||
POST /api/auth/login // 登录
|
||
GET /api/auth/me // 获取当前用户信息
|
||
```
|
||
|
||
#### 会议管理
|
||
```javascript
|
||
POST /api/meetings // 创建会议
|
||
GET /api/meetings // 获取会议列表
|
||
?user_id=xxx&page=1&page_size=20
|
||
GET /api/meetings/{id} // 获取会议详情
|
||
DELETE /api/meetings/{id} // 删除会议
|
||
|
||
POST /api/meetings/upload-audio // 上传音频(完整文件)
|
||
- meeting_id
|
||
- audio_file
|
||
- auto_summarize (false)
|
||
```
|
||
|
||
### 5.2 需要新增的接口(分片上传,可选)
|
||
|
||
```javascript
|
||
POST /api/audio/upload-init
|
||
// 初始化分片上传
|
||
Request: {
|
||
meeting_id: number,
|
||
estimated_duration: number, // 预计时长(秒)
|
||
mime_type: string
|
||
}
|
||
Response: {
|
||
session_id: string
|
||
}
|
||
|
||
POST /api/audio/upload-chunk
|
||
// 上传音频分片
|
||
Request: FormData {
|
||
session_id: string,
|
||
chunk_index: number,
|
||
chunk: Blob
|
||
}
|
||
Response: {
|
||
success: boolean
|
||
}
|
||
|
||
POST /api/audio/upload-complete
|
||
// 完成上传
|
||
Request: {
|
||
session_id: string,
|
||
meeting_id: number,
|
||
total_chunks: number
|
||
}
|
||
Response: {
|
||
file_path: string,
|
||
task_id: string // 转录任务ID
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、关键技术实现
|
||
|
||
### 6.1 音频格式兼容性
|
||
|
||
| 浏览器 | 推荐格式 | 备选格式 |
|
||
|--------|---------|---------|
|
||
| Chrome | webm(opus) | webm |
|
||
| Safari | mp4(aac) | - |
|
||
| Firefox | webm(opus) | ogg |
|
||
| Edge | webm(opus) | webm |
|
||
|
||
**检测代码:**
|
||
```javascript
|
||
const getSupportedMimeType = () => {
|
||
const types = [
|
||
'audio/webm;codecs=opus', // Chrome, Firefox, Edge
|
||
'audio/mp4', // Safari
|
||
'audio/webm', // 通用webm
|
||
'audio/ogg;codecs=opus' // Firefox fallback
|
||
];
|
||
|
||
for (const type of types) {
|
||
if (MediaRecorder.isTypeSupported(type)) {
|
||
console.log('Using MIME type:', type);
|
||
return type;
|
||
}
|
||
}
|
||
|
||
throw new Error('No supported audio MIME type found');
|
||
};
|
||
```
|
||
|
||
### 6.2 权限管理
|
||
|
||
```javascript
|
||
// utils/permissions.js
|
||
export const requestMicrophonePermission = async () => {
|
||
try {
|
||
// 尝试获取麦克风流
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
// 立即停止流(仅用于权限检查)
|
||
stream.getTracks().forEach(track => track.stop());
|
||
|
||
return { granted: true };
|
||
} catch (error) {
|
||
if (error.name === 'NotAllowedError') {
|
||
return {
|
||
granted: false,
|
||
error: '用户拒绝了麦克风权限'
|
||
};
|
||
} else if (error.name === 'NotFoundError') {
|
||
return {
|
||
granted: false,
|
||
error: '未找到麦克风设备'
|
||
};
|
||
} else {
|
||
return {
|
||
granted: false,
|
||
error: '获取麦克风权限失败'
|
||
};
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
### 6.3 状态管理(Context)
|
||
|
||
```javascript
|
||
// context/AppContext.jsx
|
||
import { createContext, useContext, useState } from 'react';
|
||
|
||
const AppContext = createContext();
|
||
|
||
export const AppProvider = ({ children }) => {
|
||
const [user, setUser] = useState(null);
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
|
||
const value = {
|
||
user,
|
||
setUser,
|
||
isRecording,
|
||
setIsRecording
|
||
};
|
||
|
||
return (
|
||
<AppContext.Provider value={value}>
|
||
{children}
|
||
</AppContext.Provider>
|
||
);
|
||
};
|
||
|
||
export const useApp = () => {
|
||
const context = useContext(AppContext);
|
||
if (!context) {
|
||
throw new Error('useApp must be used within AppProvider');
|
||
}
|
||
return context;
|
||
};
|
||
```
|
||
|
||
### 6.4 响应式布局实现
|
||
|
||
```css
|
||
/* pages/Home/Home.module.css */
|
||
.home {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 20px;
|
||
max-width: 600px;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 移动端 */
|
||
@media (max-width: 768px) {
|
||
.grid {
|
||
grid-template-columns: 1fr;
|
||
max-width: 400px;
|
||
}
|
||
}
|
||
|
||
/* 功能卡片 */
|
||
.card {
|
||
aspect-ratio: 1;
|
||
border-radius: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.card:hover {
|
||
transform: translateY(-5px);
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.card.active {
|
||
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
||
color: white;
|
||
}
|
||
|
||
.card.inactive {
|
||
background: #E0E0E0;
|
||
color: #999;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.card.inactive:hover {
|
||
transform: none;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 七、用户体验优化
|
||
|
||
### 7.1 录音反馈
|
||
```javascript
|
||
// 视觉反馈:录音按钮呼吸动画
|
||
@keyframes pulse {
|
||
0%, 100% { transform: scale(1); }
|
||
50% { transform: scale(1.05); }
|
||
}
|
||
|
||
.recording {
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
// 红点闪烁
|
||
@keyframes blink {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
.recording-indicator {
|
||
animation: blink 1s infinite;
|
||
}
|
||
```
|
||
|
||
### 7.2 加载状态
|
||
```javascript
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// 上传时显示进度
|
||
{loading && (
|
||
<div className="loading-overlay">
|
||
<div className="spinner"></div>
|
||
<p>正在上传录音...</p>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
### 7.3 错误提示
|
||
```javascript
|
||
// components/Toast.jsx
|
||
const Toast = ({ message, type = 'info' }) => (
|
||
<div className={`toast toast-${type}`}>
|
||
{message}
|
||
</div>
|
||
);
|
||
|
||
// 使用
|
||
showToast('录音已保存', 'success');
|
||
showToast('上传失败,请重试', 'error');
|
||
```
|
||
|
||
---
|
||
|
||
## 八、安全性考虑
|
||
|
||
### 8.1 Token管理
|
||
```javascript
|
||
// services/api.js
|
||
import axios from 'axios';
|
||
|
||
const api = axios.create({
|
||
baseURL: import.meta.env.VITE_API_BASE_URL
|
||
});
|
||
|
||
// 请求拦截器:添加Token
|
||
api.interceptors.request.use(config => {
|
||
const token = localStorage.getItem('token');
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
return config;
|
||
});
|
||
|
||
// 响应拦截器:处理401
|
||
api.interceptors.response.use(
|
||
response => response,
|
||
error => {
|
||
if (error.response?.status === 401) {
|
||
localStorage.removeItem('token');
|
||
window.location.href = '/login';
|
||
}
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
export default api;
|
||
```
|
||
|
||
### 8.2 数据加密
|
||
- HTTPS传输(生产环境必须)
|
||
- 录音数据不在本地长期存储
|
||
- Token使用JWT,设置合理过期时间
|
||
|
||
---
|
||
|
||
## 九、部署方案
|
||
|
||
### 9.1 构建配置
|
||
|
||
```javascript
|
||
// vite.config.js
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
server: {
|
||
port: 3002,
|
||
proxy: {
|
||
'/api': {
|
||
target: 'http://localhost:8000',
|
||
changeOrigin: true
|
||
}
|
||
}
|
||
},
|
||
build: {
|
||
outDir: 'dist',
|
||
sourcemap: false,
|
||
minify: 'terser'
|
||
}
|
||
});
|
||
```
|
||
|
||
### 9.2 环境变量
|
||
|
||
```bash
|
||
# .env.development
|
||
VITE_API_BASE_URL=http://localhost:8000
|
||
|
||
# .env.production
|
||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||
```
|
||
|
||
### 9.3 部署方式
|
||
|
||
**方案1:独立部署(推荐)**
|
||
```bash
|
||
npm run build
|
||
# 将 dist/ 部署到 Nginx/CDN
|
||
```
|
||
|
||
**Nginx配置:**
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name client.yourdomain.com;
|
||
|
||
root /var/www/client/dist;
|
||
index index.html;
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
location /api {
|
||
proxy_pass http://backend:8000;
|
||
}
|
||
}
|
||
```
|
||
|
||
**方案2:集成到现有frontend**
|
||
```
|
||
frontend/
|
||
├── src/ # 原有管理端
|
||
└── client/ # 新客户端
|
||
└── build后集成
|
||
```
|
||
|
||
---
|
||
|
||
## 十、后端接口规划
|
||
|
||
### 10.1 用户认证接口
|
||
|
||
#### 1. 用户登录
|
||
```javascript
|
||
POST /api/auth/login
|
||
|
||
Request:
|
||
{
|
||
"username": "string",
|
||
"password": "string"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "登录成功",
|
||
"data": {
|
||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"user": {
|
||
"user_id": 1,
|
||
"username": "testuser",
|
||
"caption": "测试用户"
|
||
}
|
||
}
|
||
}
|
||
|
||
Error Response:
|
||
{
|
||
"code": "401",
|
||
"message": "用户名或密码错误",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
#### 2. 获取当前用户信息
|
||
```javascript
|
||
GET /api/auth/me
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "获取成功",
|
||
"data": {
|
||
"user_id": 1,
|
||
"username": "testuser",
|
||
"caption": "测试用户",
|
||
"created_at": "2025-01-20T10:00:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 10.2 会议管理接口
|
||
|
||
#### 1. 创建会议
|
||
```javascript
|
||
POST /api/meetings
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}"
|
||
}
|
||
|
||
Request:
|
||
{
|
||
"title": "临时会议", // 可选,默认"临时会议-时间戳"
|
||
"meeting_time": "2025-01-25T14:30:00", // 可选,默认当前时间
|
||
"attendee_ids": [], // 可选,参会人员ID数组
|
||
"tags": "" // 可选,标签
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "会议创建成功",
|
||
"data": {
|
||
"meeting_id": 123,
|
||
"title": "临时会议-20250125143000",
|
||
"meeting_time": "2025-01-25T14:30:00",
|
||
"user_id": 1,
|
||
"created_at": "2025-01-25T14:30:00"
|
||
}
|
||
}
|
||
|
||
说明:
|
||
- Web客户端录音前先调用此接口创建会议记录
|
||
- 录音完成后,使用meeting_id上传音频
|
||
```
|
||
|
||
#### 2. 获取会议列表
|
||
```javascript
|
||
GET /api/meetings
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}"
|
||
}
|
||
|
||
Query Parameters:
|
||
{
|
||
"user_id": 1, // 必填:当前用户ID
|
||
"page": 1, // 可选:页码,默认1
|
||
"page_size": 20, // 可选:每页数量,默认20
|
||
"filter_type": "created" // 可选:筛选类型 (created/attended/all),默认created
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "获取会议列表成功",
|
||
"data": {
|
||
"meetings": [
|
||
{
|
||
"meeting_id": 123,
|
||
"title": "产品需求讨论会",
|
||
"meeting_time": "2025-01-25T14:30:00",
|
||
"summary": "会议讨论了新版本的产品需求...",
|
||
"created_at": "2025-01-25T14:25:00",
|
||
"audio_file_path": "/uploads/audio/123/xxx.webm",
|
||
"creator_id": 1,
|
||
"creator_username": "张三",
|
||
"attendees": [
|
||
{ "user_id": 2, "caption": "李四" },
|
||
{ "user_id": 3, "caption": "王五" }
|
||
],
|
||
"tags": [
|
||
{ "id": 1, "name": "产品", "color": "#FF5733" }
|
||
]
|
||
}
|
||
],
|
||
"total": 45,
|
||
"page": 1,
|
||
"page_size": 20,
|
||
"total_pages": 3,
|
||
"has_more": true
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 10.3 流式音频上传接口(新增)
|
||
|
||
#### 1. 初始化上传会话
|
||
```javascript
|
||
POST /api/audio/stream/init
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
Request:
|
||
{
|
||
"meeting_id": 123,
|
||
"mime_type": "audio/webm;codecs=opus", // 前端录音格式
|
||
"estimated_duration": 300 // 预计时长(秒),可选
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "上传会话初始化成功",
|
||
"data": {
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"chunk_size": 1024000, // 建议分片大小(字节),1MB
|
||
"max_chunks": 1000 // 最大分片数量
|
||
}
|
||
}
|
||
|
||
说明:
|
||
- 后端创建临时目录存储分片
|
||
- session_id用于后续上传分片
|
||
- 返回建议的分片大小
|
||
```
|
||
|
||
#### 2. 上传音频分片
|
||
```javascript
|
||
POST /api/audio/stream/chunk
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}",
|
||
"Content-Type": "multipart/form-data"
|
||
}
|
||
|
||
Request: FormData
|
||
{
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"chunk_index": 0, // 分片序号,从0开始
|
||
"chunk": <Blob>, // 音频分片数据
|
||
"is_last": false // 是否为最后一片
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "分片上传成功",
|
||
"data": {
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"chunk_index": 0,
|
||
"received": true,
|
||
"total_received": 1 // 已接收的分片总数
|
||
}
|
||
}
|
||
|
||
Error Response (需要重传):
|
||
{
|
||
"code": "500",
|
||
"message": "分片上传失败",
|
||
"data": {
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"chunk_index": 0,
|
||
"should_retry": true
|
||
}
|
||
}
|
||
|
||
说明:
|
||
- 前端每1秒收集一次音频数据(MediaRecorder.start(1000))
|
||
- 实时上传每个分片,不等待录音结束
|
||
- 支持失败重传
|
||
- chunk_index连续递增
|
||
```
|
||
|
||
#### 3. 完成上传并合并
|
||
```javascript
|
||
POST /api/audio/stream/complete
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
Request:
|
||
{
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"meeting_id": 123,
|
||
"total_chunks": 180, // 总分片数
|
||
"mime_type": "audio/webm;codecs=opus",
|
||
"auto_transcribe": true // 是否自动启动转录
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "音频上传完成",
|
||
"data": {
|
||
"meeting_id": 123,
|
||
"file_path": "/uploads/audio/123/abc123.webm",
|
||
"file_size": 184320000, // 字节
|
||
"duration": 180, // 秒
|
||
"task_id": "task_transcription_123", // 转录任务ID(如果auto_transcribe=true)
|
||
"task_status": "pending"
|
||
}
|
||
}
|
||
|
||
Error Response (合并失败):
|
||
{
|
||
"code": "500",
|
||
"message": "音频合并失败:部分分片丢失",
|
||
"data": {
|
||
"missing_chunks": [5, 12, 34], // 缺失的分片序号
|
||
"should_retry": true
|
||
}
|
||
}
|
||
|
||
说明:
|
||
- 后端按序合并所有分片
|
||
- 验证分片完整性
|
||
- 保存最终音频文件
|
||
- 可选自动启动转录任务
|
||
- 清理临时分片文件
|
||
```
|
||
|
||
#### 4. 取消上传会话
|
||
```javascript
|
||
DELETE /api/audio/stream/cancel
|
||
|
||
Headers:
|
||
{
|
||
"Authorization": "Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
Request:
|
||
{
|
||
"session_id": "sess_1737804123456_abc123"
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"code": "200",
|
||
"message": "上传会话已取消",
|
||
"data": {
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"cleaned": true
|
||
}
|
||
}
|
||
|
||
说明:
|
||
- 用户中途停止录音或取消上传时调用
|
||
- 后端清理临时分片文件
|
||
- 释放会话资源
|
||
```
|
||
|
||
---
|
||
|
||
### 10.4 接口调用流程
|
||
|
||
#### 完整录音上传流程
|
||
|
||
```
|
||
1. 用户点击录音按钮
|
||
↓
|
||
2. 前端:创建会议
|
||
POST /api/meetings
|
||
→ 获得 meeting_id
|
||
↓
|
||
3. 前端:初始化上传会话
|
||
POST /api/audio/stream/init
|
||
→ 获得 session_id
|
||
↓
|
||
4. 前端:开始录音(MediaRecorder)
|
||
↓
|
||
5. 每1秒收集音频数据
|
||
↓
|
||
6. 实时上传分片
|
||
POST /api/audio/stream/chunk
|
||
(chunk_index: 0, 1, 2, ...)
|
||
↓
|
||
7. 用户点击停止
|
||
↓
|
||
8. 前端:完成上传
|
||
POST /api/audio/stream/complete
|
||
(is_last=true, total_chunks=N)
|
||
↓
|
||
9. 后端:合并分片 → 启动转录
|
||
↓
|
||
10. 前端:跳转到会议详情页
|
||
/meetings/{meeting_id}
|
||
```
|
||
|
||
#### 错误恢复流程
|
||
|
||
```
|
||
分片上传失败
|
||
↓
|
||
前端:重试该分片(最多3次)
|
||
↓
|
||
成功 → 继续下一分片
|
||
↓
|
||
失败 → 提示用户,取消上传
|
||
DELETE /api/audio/stream/cancel
|
||
```
|
||
|
||
---
|
||
|
||
### 10.5 后端实现要点
|
||
|
||
#### 1. 分片存储策略
|
||
```python
|
||
# 后端临时目录结构
|
||
/tmp/audio_uploads/
|
||
└── sess_1737804123456_abc123/
|
||
├── chunk_0000.webm
|
||
├── chunk_0001.webm
|
||
├── chunk_0002.webm
|
||
└── metadata.json
|
||
|
||
# metadata.json
|
||
{
|
||
"session_id": "sess_1737804123456_abc123",
|
||
"meeting_id": 123,
|
||
"mime_type": "audio/webm;codecs=opus",
|
||
"total_chunks": null, # 初始为null,完成时填入
|
||
"received_chunks": [0, 1, 2, ...],
|
||
"created_at": "2025-01-25T14:30:00",
|
||
"expires_at": "2025-01-25T15:30:00" # 1小时后过期
|
||
}
|
||
```
|
||
|
||
#### 2. 分片合并逻辑
|
||
```python
|
||
def merge_audio_chunks(session_id, total_chunks):
|
||
"""合并音频分片"""
|
||
session_dir = f"/tmp/audio_uploads/{session_id}"
|
||
|
||
# 1. 验证分片完整性
|
||
missing = []
|
||
for i in range(total_chunks):
|
||
if not os.path.exists(f"{session_dir}/chunk_{i:04d}.webm"):
|
||
missing.append(i)
|
||
|
||
if missing:
|
||
raise ValueError(f"Missing chunks: {missing}")
|
||
|
||
# 2. 按序合并
|
||
output_path = f"/app/uploads/audio/{meeting_id}/{uuid.uuid4()}.webm"
|
||
with open(output_path, 'wb') as outfile:
|
||
for i in range(total_chunks):
|
||
chunk_path = f"{session_dir}/chunk_{i:04d}.webm"
|
||
with open(chunk_path, 'rb') as infile:
|
||
outfile.write(infile.read())
|
||
|
||
# 3. 清理临时文件
|
||
shutil.rmtree(session_dir)
|
||
|
||
return output_path
|
||
```
|
||
|
||
#### 3. 会话过期清理
|
||
```python
|
||
# 定时任务:清理1小时前的过期会话
|
||
@scheduler.scheduled_job('interval', hours=1)
|
||
def cleanup_expired_sessions():
|
||
"""清理过期的上传会话"""
|
||
now = datetime.now()
|
||
upload_dir = "/tmp/audio_uploads"
|
||
|
||
for session_dir in os.listdir(upload_dir):
|
||
metadata_path = f"{upload_dir}/{session_dir}/metadata.json"
|
||
if os.path.exists(metadata_path):
|
||
with open(metadata_path) as f:
|
||
metadata = json.load(f)
|
||
|
||
expires_at = datetime.fromisoformat(metadata['expires_at'])
|
||
if now > expires_at:
|
||
shutil.rmtree(f"{upload_dir}/{session_dir}")
|
||
print(f"Cleaned up expired session: {session_dir}")
|
||
```
|
||
|
||
#### 4. 音频格式处理
|
||
```python
|
||
# 支持的音频格式
|
||
SUPPORTED_MIME_TYPES = {
|
||
'audio/webm;codecs=opus': '.webm',
|
||
'audio/webm': '.webm',
|
||
'audio/ogg;codecs=opus': '.ogg',
|
||
'audio/mp4': '.m4a'
|
||
}
|
||
|
||
def validate_mime_type(mime_type):
|
||
"""验证MIME类型"""
|
||
if mime_type not in SUPPORTED_MIME_TYPES:
|
||
raise ValueError(f"Unsupported MIME type: {mime_type}")
|
||
return SUPPORTED_MIME_TYPES[mime_type]
|
||
```
|
||
|
||
---
|
||
|
||
### 10.6 性能优化建议
|
||
|
||
#### 1. 并发控制
|
||
```python
|
||
# 限制每个用户的并发上传会话数
|
||
MAX_CONCURRENT_SESSIONS_PER_USER = 2
|
||
|
||
# 使用Redis记录用户的活跃会话
|
||
redis.sadd(f"user:{user_id}:sessions", session_id)
|
||
if redis.scard(f"user:{user_id}:sessions") > MAX_CONCURRENT_SESSIONS_PER_USER:
|
||
raise TooManyConcurrentSessions()
|
||
```
|
||
|
||
#### 2. 分片上传队列
|
||
```python
|
||
# 使用Celery异步处理分片保存
|
||
@celery.task
|
||
def save_audio_chunk(session_id, chunk_index, chunk_data):
|
||
"""异步保存音频分片"""
|
||
session_dir = f"/tmp/audio_uploads/{session_id}"
|
||
chunk_path = f"{session_dir}/chunk_{chunk_index:04d}.webm"
|
||
|
||
with open(chunk_path, 'wb') as f:
|
||
f.write(chunk_data)
|
||
|
||
# 更新metadata
|
||
update_session_metadata(session_id, chunk_index)
|
||
```
|
||
|
||
#### 3. 带宽优化
|
||
```python
|
||
# 配置Nginx限速(可选)
|
||
location /api/audio/stream/chunk {
|
||
limit_req zone=upload_rate burst=5;
|
||
limit_rate 2m; # 限制每个连接2MB/s
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 10.7 安全性措施
|
||
|
||
#### 1. 文件大小限制
|
||
```python
|
||
MAX_CHUNK_SIZE = 2 * 1024 * 1024 # 2MB per chunk
|
||
MAX_TOTAL_SIZE = 500 * 1024 * 1024 # 500MB total
|
||
MAX_DURATION = 3600 # 1 hour max recording
|
||
|
||
@app.post("/api/audio/stream/chunk")
|
||
async def upload_chunk(
|
||
chunk: UploadFile = File(...),
|
||
session_id: str = Form(...),
|
||
chunk_index: int = Form(...)
|
||
):
|
||
# 验证分片大小
|
||
if chunk.size > MAX_CHUNK_SIZE:
|
||
raise HTTPException(400, "Chunk too large")
|
||
|
||
# 验证总大小
|
||
session_total = get_session_total_size(session_id)
|
||
if session_total + chunk.size > MAX_TOTAL_SIZE:
|
||
raise HTTPException(400, "Total size exceeds limit")
|
||
```
|
||
|
||
#### 2. 防止目录遍历攻击
|
||
```python
|
||
def validate_session_id(session_id: str):
|
||
"""验证session_id格式,防止路径注入"""
|
||
if not re.match(r'^sess_\d+_[a-zA-Z0-9]+$', session_id):
|
||
raise ValueError("Invalid session_id format")
|
||
return session_id
|
||
```
|
||
|
||
#### 3. 权限验证
|
||
```python
|
||
@app.post("/api/audio/stream/complete")
|
||
async def complete_upload(
|
||
request: CompleteUploadRequest,
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
# 验证会议所有权
|
||
meeting = get_meeting(request.meeting_id)
|
||
if meeting.user_id != current_user['user_id']:
|
||
raise HTTPException(403, "Permission denied")
|
||
```
|
||
|
||
---
|
||
|
||
### 10.8 监控与日志
|
||
|
||
#### 关键指标
|
||
```python
|
||
# 记录以下指标
|
||
- 上传会话数(总数、活跃数)
|
||
- 分片上传成功率
|
||
- 平均上传速度
|
||
- 合并耗时
|
||
- 失败原因统计
|
||
|
||
# 日志格式
|
||
{
|
||
"event": "chunk_upload",
|
||
"session_id": "sess_xxx",
|
||
"chunk_index": 10,
|
||
"chunk_size": 1024000,
|
||
"upload_time_ms": 234,
|
||
"user_id": 1,
|
||
"timestamp": "2025-01-25T14:30:00"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 十一、快速开始
|
||
|
||
```bash
|
||
# 1. 安装依赖
|
||
npm install
|
||
|
||
# 2. 启动开发服务器
|
||
npm run dev
|
||
|
||
# 3. 构建生产版本
|
||
npm run build
|
||
|
||
# 4. 预览生产构建
|
||
npm run preview
|
||
```
|
||
|
||
---
|
||
|
||
**文档版本**:v1.0
|
||
**最后更新**:2025-01-25
|