commit 5bbe57d56b1ac34d994e2ceae7b15958a281cebc Author: mula.liu Date: Tue Dec 2 18:54:36 2025 +0800 网页客户端 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0fcf912 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# API 后端地址配置示例 +# 开发环境可以使用代理(vite.config.js中配置),或直接指定后端地址 + +# 后端 API 地址(不需要 /api 后缀,会自动添加) +VITE_API_BASE_URL=http://localhost:8000/api + +# 录音配置 +# 分片上传间隔(毫秒),默认10000(10秒) +VITE_AUDIO_CHUNK_INTERVAL=10000 + +# 其他示例: +# 生产环境 +# VITE_API_BASE_URL=https://api.yourserver.com/api + +# 其他服务器 +# VITE_API_BASE_URL=http://192.168.1.100:8000/api + +# 更频繁的上传(5秒) +# VITE_AUDIO_CHUNK_INTERVAL=5000 + +# 更长的上传间隔(30秒) +# VITE_AUDIO_CHUNK_INTERVAL=30000 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..6b3990d --- /dev/null +++ b/.env.production @@ -0,0 +1,7 @@ +# 生产环境配置 +# 后端 API 地址(部署时请修改为实际的后端地址) +VITE_API_BASE_URL=http://api.imeeting.unisspace.com/api + +# 录音配置 +# 分片上传间隔(毫秒),默认10000(10秒) +VITE_AUDIO_CHUNK_INTERVAL=10000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d1f044 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env +.env.local +.env.*.local diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..a7daab7 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,251 @@ +# iMeeting Client 部署指南 + +## 前后端分离部署说明 + +iMeeting Client 是一个独立的前端应用,可以部署到任何静态文件服务器,与后端服务器完全分离。 + +## 配置后端地址 + +### 1. 环境变量配置 + +前端通过环境变量 `VITE_API_BASE_URL` 配置后端API地址。 + +**开发环境** (`.env`): +```bash +VITE_API_BASE_URL=http://localhost:8000/api +``` + +**生产环境** (`.env.production`): +```bash +VITE_API_BASE_URL=https://your-backend-server.com/api +``` + +### 2. 配置文件说明 + +- `.env` - 本地开发环境配置(不提交到git) +- `.env.production` - 生产环境配置(需要修改为实际后端地址) +- `.env.example` - 配置示例文件(提交到git供参考) + +## 部署方案 + +### 方案一:同一服务器不同端口 + +**后端**: `http://your-server.com:8000` +**前端**: `http://your-server.com:3002` + +修改 `.env.production`: +```bash +VITE_API_BASE_URL=http://your-server.com:8000/api +``` + +### 方案二:不同服务器 + +**后端**: `http://backend-server.com:8000` +**前端**: `http://frontend-server.com` + +修改 `.env.production`: +```bash +VITE_API_BASE_URL=http://backend-server.com:8000/api +``` + +### 方案三:使用域名(推荐) + +**后端**: `https://api.yourdomain.com` +**前端**: `https://meeting.yourdomain.com` + +修改 `.env.production`: +```bash +VITE_API_BASE_URL=https://api.yourdomain.com/api +``` + +## 构建和部署步骤 + +### 1. 修改生产环境配置 + +编辑 `.env.production` 文件,设置正确的后端地址: +```bash +VITE_API_BASE_URL=https://your-actual-backend.com/api +``` + +### 2. 安装依赖 + +```bash +npm install +# 或 +yarn install +``` + +### 3. 构建生产版本 + +```bash +npm run build +# 或 +yarn build +``` + +构建完成后,会在 `dist` 目录生成静态文件。 + +### 4. 部署静态文件 + +将 `dist` 目录部署到任何静态文件服务器: + +#### 使用 Nginx +```nginx +server { + listen 80; + server_name your-frontend-domain.com; + + root /path/to/imeeting/client/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # 如果需要CORS(跨域),添加以下配置 + # location /api { + # proxy_pass http://your-backend-server:8000; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # } +} +``` + +#### 使用 Apache +```apache + + ServerName your-frontend-domain.com + DocumentRoot /path/to/imeeting/client/dist + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + # 支持SPA路由 + RewriteEngine On + RewriteBase / + RewriteRule ^index\.html$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.html [L] + + +``` + +#### 使用 Node.js (serve) +```bash +npm install -g serve +serve -s dist -p 3002 +``` + +#### 使用 Docker +创建 `Dockerfile`: +```dockerfile +FROM nginx:alpine + +# 复制构建产物 +COPY dist /usr/share/nginx/html + +# 复制nginx配置(支持SPA路由) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +创建 `nginx.conf`: +```nginx +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +构建和运行: +```bash +docker build -t imeeting-client . +docker run -d -p 3002:80 imeeting-client +``` + +## CORS 跨域配置 + +如果前后端部署在不同域名,需要在**后端**配置CORS: + +**FastAPI 后端** (`main.py`): +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://your-frontend-domain.com", + "https://your-frontend-domain.com" + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +## 环境变量优先级 + +1. `.env.production` - 生产环境(`npm run build` 时使用) +2. `.env` - 本地开发环境(`npm run dev` 时使用) +3. 默认值 - `/api`(如果未配置环境变量,则使用相对路径) + +## 验证部署 + +1. 访问前端地址,检查页面是否正常加载 +2. 打开浏览器开发者工具 - Network +3. 尝试登录,查看API请求是否指向正确的后端地址 +4. 检查是否有CORS错误 + +## 常见问题 + +### 1. API请求404 +检查 `.env.production` 中的地址是否正确,包括端口号和路径。 + +### 2. CORS错误 +在后端添加CORS中间件,允许前端域名。 + +### 3. 刷新页面404 +配置服务器(Nginx/Apache)支持SPA路由,将所有请求重定向到 `index.html`。 + +### 4. 环境变量未生效 +- 重新构建:删除 `dist` 目录后重新 `npm run build` +- 确认环境变量名以 `VITE_` 开头 +- 重启开发服务器(开发环境) + +## 生产环境检查清单 + +- [ ] 修改 `.env.production` 为实际后端地址 +- [ ] 运行 `npm run build` 构建生产版本 +- [ ] 后端配置CORS允许前端域名 +- [ ] 配置Web服务器支持SPA路由 +- [ ] 测试登录功能 +- [ ] 测试录音功能 +- [ ] 测试会议列表 +- [ ] 检查浏览器控制台无错误 + +## 更新部署 + +每次更新前端代码后: +```bash +# 1. 拉取最新代码 +git pull + +# 2. 重新构建 +npm run build + +# 3. 更新静态文件 +# 将 dist 目录内容复制到Web服务器 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..77081ae --- /dev/null +++ b/README.md @@ -0,0 +1,1459 @@ +# 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 ( +
+ {meetings.map(meeting => ( +
+

{meeting.title}

+

{formatDateTime(meeting.meeting_time)}

+

{meeting.summary?.substring(0, 100)}...

+ +
+ ))} + + {showQR && ( + setShowQR(false)} + /> + )} +
+ ); +}; +``` + +#### 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 ( +
+
e.stopPropagation()}> +

{meeting.title}

+ +
+ +
+ +

扫描二维码查看会议详情

+ +
+

会议时间:{formatDateTime(meeting.meeting_time)}

+

创建人:{meeting.creator_username}

+

参会人数:{meeting.attendees?.length || 0}

+
+ + +
+
+ ); +}; +``` + +**二维码跳转:** +- 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 ( + + {children} + + ); +}; + +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 && ( +
+
+

正在上传录音...

+
+)} +``` + +### 7.3 错误提示 +```javascript +// components/Toast.jsx +const Toast = ({ message, type = 'info' }) => ( +
+ {message} +
+); + +// 使用 +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": , // 音频分片数据 + "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 diff --git a/design.png b/design.png new file mode 100644 index 0000000..4dbe288 Binary files /dev/null and b/design.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..9d1dc87 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + iMeeting - 微客户端 + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..52fe8a8 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "imeeting-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.0", + "qrcode.react": "^3.1.0", + "lucide-react": "^0.292.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.8" + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..fbf466b --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,39 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import Home from './pages/Home/Home'; +import Record from './pages/Record/Record'; +import Meetings from './pages/Meetings/Meetings'; +import { authService } from './services/auth'; +import './styles/global.css'; + +// 私有路由保护 +function PrivateRoute({ children }) { + return authService.isAuthenticated() ? children : ; +} + +function App() { + return ( + + + } /> + + + + } + /> + + + + } + /> + + + ); +} + +export default App; diff --git a/src/components/Header.css b/src/components/Header.css new file mode 100644 index 0000000..761b003 --- /dev/null +++ b/src/components/Header.css @@ -0,0 +1,128 @@ +.app-header { + background-color: #ffffff; + padding: 16px 24px; + border-bottom: 1px solid #e0e0e0; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + color: var(--primary-orange, #FF8C42); + width: 32px; + height: 32px; +} + +.logo-text { + font-size: 1.5rem; + font-weight: 600; + color: #2c3e50; +} + +.user-actions { + position: relative; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s; + user-select: none; +} + +.user-menu-trigger:hover { + background-color: #f5f5f5; +} + +.welcome-text { + font-size: 14px; + color: #2c3e50; + font-weight: 500; +} + +.dropdown-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 999; +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 160px; + padding: 8px 0; + z-index: 1000; + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 14px; + color: #2c3e50; +} + +.dropdown-item:hover { + background-color: #f5f5f5; +} + +.dropdown-item svg { + color: #8c8c8c; +} + +@media (max-width: 768px) { + .app-header { + padding: 12px 16px; + } + + .logo-text { + font-size: 1.25rem; + } + + .welcome-text { + display: none; + } + + .user-menu-trigger { + padding: 8px; + } +} + diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..4e41129 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronDown, LogOut, MessageSquare } from 'lucide-react'; +import { authService } from '../services/auth'; +import './Header.css'; + +const Header = () => { + const [showDropdown, setShowDropdown] = useState(false); + const navigate = useNavigate(); + const user = authService.getLocalUser(); + + const handleLogout = () => { + authService.logout(); + navigate('/login'); + }; + + return ( +
+
+
+ + iMeeting +
+
+
setShowDropdown(!showDropdown)} + > + 欢迎,{user?.caption || user?.username} + +
+ {showDropdown && ( + <> +
setShowDropdown(false)} /> +
+
+ + 退出登录 +
+
+ + )} +
+
+
+ ); +}; + +export default Header; diff --git a/src/components/Toast.css b/src/components/Toast.css new file mode 100644 index 0000000..5843a6f --- /dev/null +++ b/src/components/Toast.css @@ -0,0 +1,105 @@ +.toast { + position: fixed; + top: 24px; + right: 24px; + min-width: 320px; + max-width: 480px; + padding: 16px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + z-index: 3000; + animation: slideInRight 0.3s ease-out; + background: white; +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + font-size: 14px; + line-height: 1.5; + color: #262626; +} + +.toast-close { + background: none; + border: none; + color: #8c8c8c; + font-size: 20px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: color 0.2s; +} + +.toast-close:hover { + color: #262626; +} + +/* 不同类型的样式 */ +.toast-success { + border-left: 4px solid #52c41a; +} + +.toast-success .toast-icon { + color: #52c41a; +} + +.toast-error { + border-left: 4px solid #ff4d4f; +} + +.toast-error .toast-icon { + color: #ff4d4f; +} + +.toast-warning { + border-left: 4px solid #fa8c16; +} + +.toast-warning .toast-icon { + color: #fa8c16; +} + +.toast-info { + border-left: 4px solid #1890ff; +} + +.toast-info .toast-icon { + color: #1890ff; +} + +/* 响应式 */ +@media (max-width: 640px) { + .toast { + top: 12px; + right: 12px; + left: 12px; + min-width: auto; + max-width: none; + } +} diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..d8e0fd3 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info } from 'lucide-react'; +import './Toast.css'; + +const Toast = ({ message, type = 'info', duration = 3000, onClose }) => { + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + onClose(); + }, duration); + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + const getIcon = () => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + default: + return ; + } + }; + + return ( +
+
{getIcon()}
+
{message}
+ +
+ ); +}; + +export default Toast; diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..d76b758 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/src/pages/Home/Home.css b/src/pages/Home/Home.css new file mode 100644 index 0000000..9bca55e --- /dev/null +++ b/src/pages/Home/Home.css @@ -0,0 +1,473 @@ +.home-page { + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.home-container { + width: 100%; + max-width: 576px; /* 72%: 800 * 0.72 = 576 */ +} + +.features-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; /* 72%: 20 * 0.72 ≈ 14 */ +} + +.feature-card { + aspect-ratio: 4/3; + background: white; + border-radius: 24px; + border: none; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; /* 72%: 16 * 0.72 ≈ 12 */ + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.08), + 0 2px 4px rgba(0, 0, 0, 0.04), + inset 0 -1px 0 rgba(0, 0, 0, 0.04); + position: relative; + overflow: hidden; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0) 50%); + opacity: 0; + transition: opacity 0.3s; +} + +.feature-card::after { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s; +} + +.feature-card:hover::before { + opacity: 1; +} + +.feature-card:hover::after { + opacity: 1; +} + +.feature-card.active:hover { + transform: translateY(-6px) scale(1.02); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.12), + 0 6px 12px rgba(0, 0, 0, 0.08), + inset 0 -1px 0 rgba(0, 0, 0, 0.04); +} + +.feature-card.active:active { + transform: translateY(-2px); +} + +.feature-card.inactive { + opacity: 0.5; + cursor: not-allowed; +} + +.feature-card svg { + width: 46px; /* 72%: 64 * 0.72 ≈ 46 */ + height: 46px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + transition: all 0.3s; + position: relative; + z-index: 1; +} + +.feature-card.active:hover svg { + transform: scale(1.1); + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15)); +} + +/* 橙色主题 - 增强渐变和质感 */ +.feature-card.orange.active { + background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%); + color: white; + box-shadow: + 0 4px 16px rgba(255, 107, 53, 0.3), + 0 2px 8px rgba(255, 107, 53, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 0 rgba(0, 0, 0, 0.1); +} + +.feature-card.orange.active:hover { + box-shadow: + 0 12px 40px rgba(255, 107, 53, 0.4), + 0 6px 16px rgba(255, 107, 53, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.3), + inset 0 -1px 0 rgba(0, 0, 0, 0.1); +} + +/* 灰色主题 - 增强质感 */ +.feature-card.gray { + background: linear-gradient(135deg, #E8E8E8 0%, #D5D5D5 100%); + color: #999; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.06), + inset 0 -1px 0 rgba(0, 0, 0, 0.08); +} + +/* 蓝色主题 - 用户卡片,增强渐变和质感 */ +.feature-card.blue.active { + color: #667eea; + background: linear-gradient(135deg, #f0f4ff 0%, #e0e7ff 100%); + box-shadow: + 0 4px 16px rgba(102, 126, 234, 0.2), + 0 2px 8px rgba(102, 126, 234, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.5), + inset 0 -1px 0 rgba(102, 126, 234, 0.1); +} + +.feature-card.blue.active:hover { + box-shadow: + 0 12px 40px rgba(102, 126, 234, 0.25), + 0 6px 16px rgba(102, 126, 234, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.6), + inset 0 -1px 0 rgba(102, 126, 234, 0.1); +} + +.feature-title { + font-size: 19px; /* 72%: 20 * 0.8 * 1.2 ≈ 19 */ + font-weight: 600; + text-align: center; + padding: 0 8px; + position: relative; + z-index: 1; +} + +/* 用户统计信息 - 增强质感 */ +.user-stats { + display: flex; + gap: 12px; /* 72%: 16 * 0.72 ≈ 12 */ + font-size: 13px; /* 72%: 14 * 0.85 * 1.1 ≈ 13 */ + color: #667eea; + font-weight: 500; + margin-top: 6px; + position: relative; + z-index: 1; +} + +.user-stats span { + padding: 4px 10px; /* 增大尺寸 */ + background: rgba(102, 126, 234, 0.12); + border-radius: 12px; + backdrop-filter: blur(4px); + box-shadow: 0 2px 4px rgba(102, 126, 234, 0.1); + transition: all 0.3s; +} + +.feature-card.blue.active:hover .user-stats span { + background: rgba(102, 126, 234, 0.18); + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(102, 126, 234, 0.15); +} + +/* 模态框覆盖层 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 登录模态框 */ +.login-modal { + background: white; + border-radius: 20px; + padding: 40px; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.login-modal h3 { + margin: 0 0 24px 0; + font-size: 24px; + font-weight: 600; + color: #1e293b; + text-align: center; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.login-form input { + padding: 14px 16px; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 15px; + transition: all 0.2s; + outline: none; +} + +.login-form input:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.login-form input:disabled { + background: #f1f5f9; + cursor: not-allowed; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 8px; +} + +.modal-actions button { + flex: 1; + padding: 14px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.cancel-btn { + background: #f1f5f9; + color: #64748b; +} + +.cancel-btn:hover:not(:disabled) { + background: #e2e8f0; +} + +.submit-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.submit-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +.modal-actions button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 退出确认模态框 */ +.logout-modal { + background: white; + border-radius: 20px; + padding: 40px; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + text-align: center; + animation: slideUp 0.3s ease; +} + +.logout-icon { + width: 80px; + height: 80px; + margin: 0 auto 20px; + border-radius: 50%; + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + display: flex; + align-items: center; + justify-content: center; + color: #f59e0b; +} + +.logout-modal h3 { + margin: 0 0 12px 0; + font-size: 22px; + font-weight: 600; + color: #1e293b; +} + +.logout-hint { + font-size: 15px; + color: #64748b; + margin: 0 0 24px 0; + line-height: 1.6; +} + +.logout-btn { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; +} + +.logout-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4); +} + +/* 响应式 - 高度不足时横向排列 */ +@media (max-height: 600px) { + .home-container { + max-width: 100%; + } + + .features-grid { + grid-template-columns: repeat(4, 1fr); + max-width: 1000px; + margin: 0 auto; + } + + .user-stats { + flex-direction: column; + gap: 4px; + font-size: 11px; + } +} + +/* 响应式 - 小屏幕 */ +@media (max-width: 768px) { + .home-container { + max-width: 400px; + } + + .features-grid { + gap: 10px; + } + + .feature-card { + gap: 8px; + } + + .feature-card svg { + width: 32px; + height: 32px; + } + + .feature-title { + font-size: 14px; + } + + .user-stats { + font-size: 11px; + gap: 8px; + } +} + +/* 响应式 - 横屏手机或矮屏幕 */ +@media (max-width: 768px) and (max-height: 500px) { + .features-grid { + grid-template-columns: repeat(4, 1fr); + max-width: 100%; + } + + .feature-card { + gap: 6px; + } + + .feature-card svg { + width: 28px; + height: 28px; + } + + .feature-title { + font-size: 13px; + } + + .user-stats { + flex-direction: column; + gap: 3px; + font-size: 10px; + } +} + +/* 响应式 - 超小屏幕 */ +@media (max-width: 480px) { + .home-page { + padding: 16px; + } + + .home-container { + max-width: 320px; + } + + .features-grid { + gap: 8px; + } + + .feature-card { + border-radius: 16px; + gap: 6px; + } + + .feature-card svg { + width: 28px; + height: 28px; + } + + .feature-title { + font-size: 13px; + } + + .user-stats { + flex-direction: column; + gap: 4px; + font-size: 10px; + } + + .login-modal, + .logout-modal { + padding: 30px 24px; + } + + .login-modal h3, + .logout-modal h3 { + font-size: 20px; + } +} diff --git a/src/pages/Home/Home.jsx b/src/pages/Home/Home.jsx new file mode 100644 index 0000000..f9ce847 --- /dev/null +++ b/src/pages/Home/Home.jsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Mic, Brain, FileText, User, LogOut } from 'lucide-react'; +import { authService } from '../../services/auth'; +import Toast from '../../components/Toast'; +import './Home.css'; + +function Home() { + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [userStats, setUserStats] = useState(null); + const [showLoginModal, setShowLoginModal] = useState(false); + const [showLogoutModal, setShowLogoutModal] = useState(false); + const [loginForm, setLoginForm] = useState({ username: '', password: '' }); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [toast, setToast] = useState(null); + + useEffect(() => { + checkAuthStatus(); + }, []); + + const checkAuthStatus = () => { + const currentUser = authService.getLocalUser(); + setUser(currentUser); + if (currentUser) { + fetchUserStats(); + } + }; + + const fetchUserStats = async () => { + // 从会议统计接口获取统计信息 + try { + const currentUser = authService.getLocalUser(); + if (!currentUser?.user_id) return; + + const response = await authService.getUserStats(currentUser.user_id); + setUserStats(response.data); + } catch (error) { + console.error('Failed to fetch user stats:', error); + } + }; + + const handleLogin = async (e) => { + e.preventDefault(); + if (!loginForm.username || !loginForm.password) { + setToast({ message: '请输入用户名和密码', type: 'error' }); + return; + } + + setIsLoggingIn(true); + try { + await authService.login(loginForm.username, loginForm.password); + setToast({ message: '登录成功!', type: 'success' }); + setShowLoginModal(false); + setLoginForm({ username: '', password: '' }); + checkAuthStatus(); + } catch (err) { + console.error('Login error:', err); + setToast({ + message: err.response?.data?.message || '登录失败,请检查用户名和密码', + type: 'error' + }); + } finally { + setIsLoggingIn(false); + } + }; + + const handleLogout = () => { + authService.logout(); + setUser(null); + setUserStats(null); + setShowLogoutModal(false); + setToast({ message: '已退出登录', type: 'success' }); + }; + + const handleFeatureClick = (feature) => { + if (!user && feature.requireAuth) { + setToast({ message: '请先登录', type: 'error' }); + setShowLoginModal(true); + return; + } + if (feature.onClick) { + feature.onClick(); + } + }; + + const features = [ + { + id: 'user', + title: user ? user.caption : '请点击登录', + icon: User, + active: true, + color: 'blue', + isUserCard: true, + requireAuth: false, + onClick: () => { + if (user) { + setShowLogoutModal(true); + } else { + setShowLoginModal(true); + } + } + }, + { + id: 'record', + title: '会议录音', + icon: Mic, + active: true, + color: 'orange', + requireAuth: true, + onClick: () => navigate('/record') + }, + { + id: 'records', + title: '会议记录', + icon: FileText, + active: true, + color: 'orange', + requireAuth: true, + onClick: () => navigate('/meetings') + }, + { + id: 'ai', + title: 'AI', + icon: Brain, + active: false, + color: 'gray', + requireAuth: false, + onClick: null + } + ]; + + return ( +
+ {toast && ( + setToast(null)} + /> + )} + +
+
+ {features.map((feature) => { + const Icon = feature.icon; + return ( + + ); + })} +
+
+ + {/* 登录模态框 */} + {showLoginModal && ( +
setShowLoginModal(false)}> +
e.stopPropagation()}> +

登录 iMeeting

+
+ setLoginForm({ ...loginForm, username: e.target.value })} + disabled={isLoggingIn} + autoFocus + /> + setLoginForm({ ...loginForm, password: e.target.value })} + disabled={isLoggingIn} + /> +
+ + +
+
+
+
+ )} + + {/* 退出确认模态框 */} + {showLogoutModal && ( +
setShowLogoutModal(false)}> +
e.stopPropagation()}> +
+ +
+

确认退出登录?

+

退出后需要重新登录才能使用录音和记录功能

+
+ + +
+
+
+ )} +
+ ); +} + +export default Home; diff --git a/src/pages/Login/Login.css b/src/pages/Login/Login.css new file mode 100644 index 0000000..8e423c7 --- /dev/null +++ b/src/pages/Login/Login.css @@ -0,0 +1,128 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.login-container { + width: 100%; + max-width: 400px; +} + +.login-card { + background: white; + border-radius: 20px; + padding: 40px 30px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); +} + +.login-title { + font-size: 32px; + font-weight: bold; + color: var(--primary-orange); + text-align: center; + margin-bottom: 8px; +} + +.login-subtitle { + font-size: 16px; + color: var(--text-gray); + text-align: center; + margin-bottom: 30px; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-input { + height: 48px; + padding: 0 16px; + border: 2px solid #E0E0E0; + border-radius: 10px; + font-size: 15px; + transition: border-color 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--primary-orange); +} + +.form-input:disabled { + background: #F5F5F5; + cursor: not-allowed; +} + +.remember-me { + display: flex; + align-items: center; + margin-top: -8px; + margin-bottom: -8px; +} + +.remember-me label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + font-size: 14px; + color: var(--text-gray); +} + +.remember-me input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + accent-color: var(--primary-orange); +} + +.error-message { + color: #E53E3E; + font-size: 14px; + padding: 10px; + background: #FFF5F5; + border-radius: 8px; + border: 1px solid #FEB2B2; +} + +.login-button { + height: 48px; + background: linear-gradient(135deg, var(--primary-orange) 0%, var(--primary-orange-dark) 100%); + color: white; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + transition: transform 0.2s, box-shadow 0.2s; + margin-top: 8px; +} + +.login-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 140, 66, 0.3); +} + +.login-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 480px) { + .login-card { + padding: 30px 20px; + } + + .login-title { + font-size: 28px; + } +} diff --git a/src/pages/Login/Login.jsx b/src/pages/Login/Login.jsx new file mode 100644 index 0000000..90fd7dc --- /dev/null +++ b/src/pages/Login/Login.jsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authService } from '../../services/auth'; +import Toast from '../../components/Toast'; +import './Login.css'; + +function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [rememberMe, setRememberMe] = useState(false); + const [toast, setToast] = useState(null); + const navigate = useNavigate(); + + // 组件挂载时,从localStorage读取保存的用户名 + useEffect(() => { + const savedUsername = localStorage.getItem('rememberedUsername'); + const isRemembered = localStorage.getItem('rememberMe') === 'true'; + + if (savedUsername && isRemembered) { + setUsername(savedUsername); + setRememberMe(true); + } + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!username || !password) { + const errorMsg = '请输入用户名和密码'; + setToast({ message: errorMsg, type: 'error' }); + return; + } + + setLoading(true); + + try { + await authService.login(username, password); + + // 处理记住用户名 + if (rememberMe) { + localStorage.setItem('rememberedUsername', username); + localStorage.setItem('rememberMe', 'true'); + } else { + localStorage.removeItem('rememberedUsername'); + localStorage.removeItem('rememberMe'); + } + + setToast({ message: '登录成功!', type: 'success' }); + setTimeout(() => { + navigate('/'); + }, 500); + } catch (err) { + console.error('Login error:', err); + const errorMsg = err.response?.data?.message || '登录失败,请检查用户名和密码'; + setToast({ message: errorMsg, type: 'error' }); + } finally { + setLoading(false); + } + }; + + return ( +
+ {toast && ( + setToast(null)} + /> + )} + +
+
+

iMeeting

+

微客户端

+ +
+
+ setUsername(e.target.value)} + className="form-input" + disabled={loading} + /> +
+ +
+ setPassword(e.target.value)} + className="form-input" + disabled={loading} + /> +
+ +
+ +
+ + +
+
+
+
+ ); +} + +export default Login; diff --git a/src/pages/Meetings/Meetings.css b/src/pages/Meetings/Meetings.css new file mode 100644 index 0000000..b12cf25 --- /dev/null +++ b/src/pages/Meetings/Meetings.css @@ -0,0 +1,628 @@ +.meetings-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +/* 页面头部 - 返回按钮 + 标题 */ +.page-header { + padding: 20px 24px; + display: flex; + align-items: center; + gap: 16px; + position: relative; +} + +.back-button { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 12px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: all 0.3s; + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.back-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateX(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.back-button:active { + transform: translateX(-1px) scale(0.98); +} + +.page-title { + font-size: 1.75rem; + font-weight: 600; + color: white; + margin: 0; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.meetings-container { + flex: 1; + max-width: 800px; + width: 100%; + margin: 0 auto; + padding: 40px 20px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.loading-state, +.empty-state { + text-align: center; + padding: 60px 20px; + color: white; + font-size: 18px; +} + +.meetings-list { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; +} + +.meetings-content-wrapper { + display: flex; + gap: 20px; + align-items: center; +} + +.meeting-item { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-left: 4px solid; + display: flex; + align-items: center; + gap: 16px; + transition: all 0.3s ease; + min-height: 80px; +} + +.meeting-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +/* 自己创建的 - 橙色边框 */ +.meeting-item.created-by-me { + border-left-color: #FF8C42; +} + +/* 别人创建的 - 绿色边框 */ +.meeting-item.attended-by-me { + border-left-color: #34d399; +} + +.meeting-main { + flex: 1; + cursor: pointer; +} + +.meeting-info { + display: flex; + flex-direction: column; + gap: 12px; +} + +.meeting-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1e293b; +} + +.meeting-meta { + display: flex; + gap: 24px; + flex-wrap: wrap; +} + +.meta-item { + display: flex; + align-items: center; + gap: 6px; + color: #64748b; + font-size: 14px; +} + +.meta-item svg { + flex-shrink: 0; +} + +/* 会议操作按钮组 */ +.meeting-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.qr-btn, +.delete-btn { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 8px; + background: #f1f5f9; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + border: none; + cursor: pointer; +} + +.qr-btn { + color: #667eea; +} + +.qr-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.delete-btn { + color: #ef4444; +} + +.delete-btn:hover { + background: #ef4444; + color: white; + transform: scale(1.1); +} + +/* 竖直分页器 */ +.vertical-pagination { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + background: white; + border-radius: 50px; + padding: 12px 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + position: sticky; + top: 50%; + transform: translateY(-50%); +} + +.pagination-arrow-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.pagination-arrow-btn:hover:not(:disabled) { + transform: scale(1.15); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); +} + +.pagination-arrow-btn:active:not(:disabled) { + transform: scale(1.05); +} + +.pagination-arrow-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + background: #e2e8f0; + color: #94a3b8; + box-shadow: none; +} + +.pagination-page-info { + font-size: 12px; + font-weight: 600; + color: #667eea; + padding: 4px 0; + text-align: center; + min-width: 36px; +} + +/* QR码模态框 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.qr-modal { + background: white; + border-radius: 16px; + padding: 32px; + max-width: 400px; + width: 90%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + text-align: center; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.qr-modal h3 { + margin: 0 0 24px 0; + font-size: 20px; + font-weight: 600; + color: #2c3e50; +} + +.qr-code-container { + display: flex; + justify-content: center; + margin-bottom: 20px; + padding: 20px; + background: #f8fafc; + border-radius: 12px; +} + +.qr-code-container img { + width: 200px; + height: 200px; +} + +.qr-url { + font-size: 12px; + color: #64748b; + word-break: break-all; + margin: 0 0 20px 0; + padding: 12px; + background: #f1f5f9; + border-radius: 8px; +} + +.qr-modal .close-btn { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + transition: all 0.2s; +} + +.qr-modal .close-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +/* 删除确认模态框 */ +.delete-modal { + background: white; + border-radius: 16px; + padding: 32px; + max-width: 450px; + width: 90%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + text-align: center; + animation: slideUp 0.3s ease; +} + +.delete-icon { + width: 80px; + height: 80px; + margin: 0 auto 20px; + border-radius: 50%; + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); + display: flex; + align-items: center; + justify-content: center; + color: #ef4444; +} + +.delete-modal h3 { + margin: 0 0 16px 0; + font-size: 22px; + font-weight: 600; + color: #1e293b; +} + +.delete-warning { + font-size: 16px; + color: #475569; + margin: 0 0 12px 0; + line-height: 1.6; +} + +.delete-warning strong { + color: #ef4444; + font-weight: 600; +} + +.delete-hint { + font-size: 14px; + color: #94a3b8; + margin: 0 0 24px 0; + line-height: 1.5; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.modal-actions button { + flex: 1; + padding: 12px 24px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + transition: all 0.2s; + cursor: pointer; + border: none; +} + +.cancel-btn { + background: #f1f5f9; + color: #64748b; +} + +.cancel-btn:hover:not(:disabled) { + background: #e2e8f0; +} + +.confirm-delete-btn { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; +} + +.confirm-delete-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.modal-actions button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 响应式 */ +@media (max-width: 768px) { + .meetings-page-header { + padding: 12px 16px; + } + + .meetings-page-header .logo-text { + font-size: 1.25rem; + } + + .meetings-page-header h1 { + font-size: 1.25rem; + } + + .meetings-container { + padding: 20px 16px; + } + + .meetings-content-wrapper { + flex-direction: column; + gap: 16px; + } + + .vertical-pagination { + flex-direction: row; + position: static; + transform: none; + width: 100%; + justify-content: center; + padding: 8px 12px; + border-radius: 50px; + } + + .pagination-arrow-btn { + width: 40px; + height: 40px; + } + + .meeting-item { + padding: 16px; + flex-direction: column; + align-items: flex-start; + } + + .meeting-actions { + align-self: flex-end; + margin-top: 8px; + } + + .meeting-meta { + flex-direction: column; + gap: 8px; + } +} + +/* 行内音频播放器 */ +.meeting-item.playing { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-color: transparent; +} + +.meeting-item.playing .meeting-main { + cursor: default; +} + +.audio-player { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + padding: 4px 0; +} + +.audio-player .play-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: all 0.3s; + flex-shrink: 0; +} + +.audio-player .play-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.05); +} + +.audio-player .play-btn:active { + transform: scale(0.95); +} + +.player-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.player-title { + font-size: 15px; + font-weight: 600; + color: white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-container { + display: flex; + align-items: center; + gap: 12px; +} + +.time-current, +.time-duration { + font-size: 12px; + color: rgba(255, 255, 255, 0.9); + font-weight: 500; + min-width: 40px; + text-align: center; +} + +.progress-bar { + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.progress-bar:hover { + height: 8px; +} + +.progress-fill { + height: 100%; + background: white; + border-radius: 3px; + transition: width 0.1s linear; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.5); +} + +.close-player-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: all 0.3s; + flex-shrink: 0; +} + +.close-player-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: rotate(90deg); +} + +.close-player-btn:active { + transform: rotate(90deg) scale(0.9); +} + +/* 响应式 - 播放器 */ +@media (max-width: 768px) { + .audio-player { + gap: 12px; + } + + .audio-player .play-btn { + width: 42px; + height: 42px; + } + + .player-title { + font-size: 14px; + } + + .time-current, + .time-duration { + font-size: 11px; + min-width: 35px; + } + + .close-player-btn { + width: 32px; + height: 32px; + } +} diff --git a/src/pages/Meetings/Meetings.jsx b/src/pages/Meetings/Meetings.jsx new file mode 100644 index 0000000..f4b7542 --- /dev/null +++ b/src/pages/Meetings/Meetings.jsx @@ -0,0 +1,410 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, Clock, User, QrCode, ChevronUp, ChevronDown, Trash2, Play, Pause, X } from 'lucide-react'; +import { meetingService } from '../../services/meeting'; +import { authService } from '../../services/auth'; +import Toast from '../../components/Toast'; +import './Meetings.css'; + +function Meetings() { + const navigate = useNavigate(); + const [meetings, setMeetings] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ + page: 1, + page_size: 5, + total: 0, + total_pages: 0 + }); + const [toast, setToast] = useState(null); + const [showQRModal, setShowQRModal] = useState(false); + const [selectedMeetingUrl, setSelectedMeetingUrl] = useState(''); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingMeeting, setDeletingMeeting] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const currentUser = authService.getLocalUser(); + + // 音频播放相关状态 + const [playingMeetingId, setPlayingMeetingId] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const audioRef = useRef(null); + + useEffect(() => { + fetchMeetings(1); + }, []); + + const fetchMeetings = async (page) => { + setLoading(true); + try { + const response = await meetingService.getMeetings({ + page, + page_size: pagination.page_size + }); + + // API响应格式: { code, message, data: { meetings, page, page_size, total, total_pages } } + const data = response.data; + setMeetings(data.meetings || []); + setPagination({ + page: data.page, + page_size: data.page_size, + total: data.total, + total_pages: data.total_pages + }); + } catch (error) { + console.error('Failed to fetch meetings:', error); + setToast({ message: '加载会议列表失败', type: 'error' }); + } finally { + setLoading(false); + } + }; + + const handlePageChange = (newPage) => { + if (newPage >= 1 && newPage <= pagination.total_pages) { + fetchMeetings(newPage); + } + }; + + const handleShowQR = (meetingId) => { + const url = `${window.location.origin}/meetings/preview/${meetingId}`; + setSelectedMeetingUrl(url); + setShowQRModal(true); + }; + + const handleDeleteClick = (meeting, e) => { + e.stopPropagation(); + setDeletingMeeting(meeting); + setShowDeleteModal(true); + }; + + const handleConfirmDelete = async () => { + if (!deletingMeeting) return; + + setIsDeleting(true); + try { + await meetingService.deleteMeeting(deletingMeeting.meeting_id); + setToast({ message: '会议删除成功', type: 'success' }); + setShowDeleteModal(false); + setDeletingMeeting(null); + + // 重新加载当前页 + // 如果当前页只有一条记录且不是第一页,则返回上一页 + const isLastItemOnPage = meetings.length === 1 && pagination.page > 1; + const targetPage = isLastItemOnPage ? pagination.page - 1 : pagination.page; + fetchMeetings(targetPage); + } catch (error) { + console.error('Failed to delete meeting:', error); + setToast({ + message: error.response?.data?.message || '删除会议失败', + type: 'error' + }); + } finally { + setIsDeleting(false); + } + }; + + const handleCancelDelete = () => { + setShowDeleteModal(false); + setDeletingMeeting(null); + }; + + // 音频播放相关函数 + const handlePlayAudio = (meetingId, e) => { + e.stopPropagation(); + + // 如果点击的是同一个会议,且正在播放,则暂停 + if (playingMeetingId === meetingId && isPlaying) { + audioRef.current?.pause(); + setIsPlaying(false); + return; + } + + // 如果点击的是不同的会议,或者同一个会议但暂停状态 + if (playingMeetingId !== meetingId) { + // 停止之前的播放 + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + // 创建新的音频元素 + const audio = new Audio(`/api/meetings/${meetingId}/audio/stream`); + audioRef.current = audio; + + // 监听音频事件 + audio.addEventListener('loadedmetadata', () => { + setDuration(audio.duration); + }); + + audio.addEventListener('timeupdate', () => { + setCurrentTime(audio.currentTime); + }); + + audio.addEventListener('ended', () => { + setIsPlaying(false); + setCurrentTime(0); + }); + + audio.addEventListener('error', (e) => { + console.error('Audio error:', e); + setToast({ message: '音频加载失败', type: 'error' }); + setPlayingMeetingId(null); + setIsPlaying(false); + }); + + setPlayingMeetingId(meetingId); + setCurrentTime(0); + audio.play(); + setIsPlaying(true); + } else { + // 同一个会议,从暂停恢复播放 + audioRef.current?.play(); + setIsPlaying(true); + } + }; + + const handlePlayPause = (e) => { + e.stopPropagation(); + if (isPlaying) { + audioRef.current?.pause(); + setIsPlaying(false); + } else { + audioRef.current?.play(); + setIsPlaying(true); + } + }; + + const handleSeek = (e) => { + const progressBar = e.currentTarget; + const rect = progressBar.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + const newTime = percent * duration; + + if (audioRef.current) { + audioRef.current.currentTime = newTime; + setCurrentTime(newTime); + } + }; + + const handleClosePlayer = (e) => { + e.stopPropagation(); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setPlayingMeetingId(null); + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + }; + + const formatTime = (seconds) => { + if (isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const formatDateTime = (dateString) => { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+ {toast && ( + setToast(null)} + /> + )} + +
+ +

会议记录

+
+ +
+ {loading ? ( +
+

加载中...

+
+ ) : meetings.length === 0 ? ( +
+

暂无会议记录

+
+ ) : ( + <> +
+
+ {meetings.map((meeting) => { + const isCreator = String(meeting.creator_id) === String(currentUser?.user_id); + const cardClass = isCreator ? 'created-by-me' : 'attended-by-me'; + const isPlaying = playingMeetingId === meeting.meeting_id; + + return ( +
+
{ + if (!isPlaying) { + handlePlayAudio(meeting.meeting_id, e); + } + }} + > + {isPlaying ? ( + /* 播放器模式 */ +
+ + +
+
{meeting.title}
+
+ {formatTime(currentTime)} +
+
+
+ {formatTime(duration)} +
+
+ + +
+ ) : ( + /* 正常信息显示 */ +
+

{meeting.title}

+
+
+ + {formatDateTime(meeting.meeting_time)} +
+
+ + 创建人: {meeting.creator_username} +
+
+
+ )} +
+ {!isPlaying && ( +
+ + {isCreator && ( + + )} +
+ )} +
+ ); + })} +
+ + {/* 竖直分页器 */} + {pagination.total_pages > 1 && ( +
+ +
+ {pagination.page}/{pagination.total_pages} +
+ +
+ )} +
+ + )} +
+ + {/* QR码模态框 */} + {showQRModal && ( +
setShowQRModal(false)}> +
e.stopPropagation()}> +

扫码查看会议

+
+ QR Code +
+

{selectedMeetingUrl}

+ +
+
+ )} + + {/* 删除确认模态框 */} + {showDeleteModal && ( +
+
e.stopPropagation()}> +
+ +
+

确认删除会议?

+

+ 确定要删除会议 "{deletingMeeting?.title}" 吗? +

+
+ + +
+
+
+ )} +
+ ); +} + +export default Meetings; diff --git a/src/pages/Record/Record.css b/src/pages/Record/Record.css new file mode 100644 index 0000000..390d4d8 --- /dev/null +++ b/src/pages/Record/Record.css @@ -0,0 +1,507 @@ +.record-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +/* 页面头部 - 返回按钮 + 标题 */ +.page-header { + padding: 20px 24px; + display: flex; + align-items: center; + gap: 16px; + position: relative; +} + +.back-button { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 12px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: all 0.3s; + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.back-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateX(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.back-button:active { + transform: translateX(-1px) scale(0.98); +} + +.page-title { + font-size: 1.75rem; + font-weight: 600; + color: white; + margin: 0; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* 主容器 */ +.record-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.record-card { + background: white; + border-radius: 30px; + padding: 40px 40px; + max-width: 600px; + width: 100%; + aspect-ratio: 4/3; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +/* 顶部提示文字区域 */ +.status-text-area { + text-align: center; + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; +} + +.status-text { + font-size: 28px; + font-weight: 600; + color: #2c3e50; + margin: 0; +} + +.status-text.success { + color: #10B981; +} + +.status-text.recording-pulse { + animation: colorPulse 2s ease-in-out infinite; +} + +@keyframes colorPulse { + 0%, 100% { + color: #2c3e50; + } + 50% { + color: #E53E3E; + } +} + +.status-hint { + font-size: 16px; + color: #64748b; + margin: 0; +} + +/* 中心按钮区域 */ +.center-button-area { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} + +/* 音频源选择按钮组 */ +.audio-source-buttons { + display: flex; + gap: 30px; + align-items: center; + justify-content: center; +} + +.source-button { + width: 160px; + aspect-ratio: 4/3; + border-radius: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + border: none; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); + color: white; + font-size: 16px; + font-weight: 600; +} + +.source-button.microphone { + background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%); +} + +.source-button.microphone:hover { + transform: translateY(-5px); + box-shadow: 0 12px 40px rgba(255, 107, 53, 0.4); +} + +.source-button.system { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.source-button.system:hover { + transform: translateY(-5px); + box-shadow: 0 12px 40px rgba(102, 126, 234, 0.4); +} + +.source-button:active { + transform: translateY(-2px); +} + +.source-button span { + font-size: 14px; +} + +/* 中心麦克风按钮 */ +.center-mic-button { + width: 200px; + height: 200px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%); + color: white; + position: relative; + z-index: 10; +} + +.center-mic-button:hover { + transform: scale(1.05); + box-shadow: 0 15px 50px rgba(255, 107, 53, 0.4); +} + +.center-mic-button:active { + transform: scale(0.98); +} + +/* 录音状态 - 红色停止按钮 */ +.center-mic-button.recording { + background: linear-gradient(135deg, #E53E3E 0%, #C53030 100%); +} + +.center-mic-button.recording:hover { + box-shadow: 0 15px 50px rgba(197, 48, 48, 0.4); +} + +/* 加载状态 - 灰色 */ +.center-mic-button.loading { + background: linear-gradient(135deg, #E0E0E0 0%, #BDBDBD 100%); + color: #757575; + cursor: not-allowed; +} + +.center-mic-button.loading:hover { + transform: none; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); +} + +/* 完成状态 - 绿色 */ +.center-mic-button.completed { + background: linear-gradient(135deg, #10B981 0%, #059669 100%); + animation: successPop 0.5s ease-out; +} + +@keyframes successPop { + 0% { + transform: scale(0.8); + opacity: 0; + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* 录音容器 - 包含波纹 */ +.recording-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 240px; + height: 240px; +} + +/* 波纹动画 */ +.ripple-wave { + position: absolute; + border-radius: 50%; + border: 3px solid #FF8C42; + opacity: 0; + animation: ripple 2s ease-out infinite; +} + +.ripple-wave.wave-1 { + animation-delay: 0s; +} + +.ripple-wave.wave-2 { + animation-delay: 0.7s; +} + +.ripple-wave.wave-3 { + animation-delay: 1.4s; +} + +@keyframes ripple { + 0% { + width: 200px; + height: 200px; + opacity: 0.8; + } + 100% { + width: 400px; + height: 400px; + opacity: 0; + } +} + +/* 旋转动画 */ +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 时长显示 */ +.duration-display { + flex: 0 0 auto; + font-size: 56px; + font-weight: 700; + color: #2c3e50; + font-variant-numeric: tabular-nums; + letter-spacing: 4px; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +/* 通用淡入动画 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 响应式 */ +@media (max-width: 768px) { + .record-page-header { + padding: 12px 16px; + } + + .record-page-header .logo-text { + font-size: 1.25rem; + } + + .record-page-header h1 { + font-size: 1.25rem; + } + + .record-card { + padding: 30px 20px; + } + + .status-text-area { + flex: 0 0 auto; + } + + .status-text { + font-size: 24px; + } + + .center-button-area { + flex: 1; + } + + .audio-source-buttons { + gap: 20px; + } + + .source-button { + width: 130px; + aspect-ratio: 4/3; + gap: 8px; + } + + .source-button svg { + width: 50px; + height: 50px; + } + + .source-button span { + font-size: 13px; + } + + .center-mic-button { + width: 160px; + height: 160px; + } + + .center-mic-button svg { + width: 60px; + height: 60px; + } + + .recording-container { + width: 200px; + height: 200px; + } + + .ripple-wave { + animation: ripple-mobile 2s ease-out infinite; + } + + @keyframes ripple-mobile { + 0% { + width: 160px; + height: 160px; + opacity: 0.8; + } + 100% { + width: 320px; + height: 320px; + opacity: 0; + } + } + + .duration-display { + font-size: 48px; + } +} + +@media (max-width: 480px) { + .record-page-header .header-content { + gap: 12px; + } + + .record-page-header .logo-icon { + width: 24px; + height: 24px; + } + + .record-page-header .logo-text { + font-size: 1.1rem; + } + + .record-page-header h1 { + font-size: 1.1rem; + } + + .record-card { + padding: 25px 20px; + } + + .status-text-area { + flex: 0 0 auto; + } + + .status-text { + font-size: 20px; + } + + .status-hint { + font-size: 14px; + } + + .center-button-area { + flex: 1; + } + + .audio-source-buttons { + flex-direction: column; + gap: 15px; + } + + .source-button { + width: 200px; + aspect-ratio: 4/3; + border-radius: 15px; + flex-direction: row; + gap: 12px; + } + + .source-button svg { + width: 32px; + height: 32px; + } + + .source-button span { + font-size: 14px; + } + + .center-mic-button { + width: 140px; + height: 140px; + } + + .center-mic-button svg { + width: 50px; + height: 50px; + } + + .recording-container { + width: 180px; + height: 180px; + } + + @keyframes ripple-small { + 0% { + width: 140px; + height: 140px; + opacity: 0.8; + } + 100% { + width: 280px; + height: 280px; + opacity: 0; + } + } + + .ripple-wave { + animation: ripple-small 2s ease-out infinite; + } + + .duration-display { + font-size: 40px; + letter-spacing: 2px; + } +} diff --git a/src/pages/Record/Record.jsx b/src/pages/Record/Record.jsx new file mode 100644 index 0000000..a6b0e92 --- /dev/null +++ b/src/pages/Record/Record.jsx @@ -0,0 +1,376 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Mic, Square, Loader, ArrowLeft, Monitor } from 'lucide-react'; +import { meetingService } from '../../services/meeting'; +import { audioService } from '../../services/audio'; +import { authService } from '../../services/auth'; +import Toast from '../../components/Toast'; +import './Record.css'; + +function Record() { + const navigate = useNavigate(); + const [isRecording, setIsRecording] = useState(false); + const [duration, setDuration] = useState(0); + const [status, setStatus] = useState('idle'); // idle, requesting-permission, recording, uploading, completed + const [audioSource, setAudioSource] = useState(null); // 'microphone' or 'system' + const [toast, setToast] = useState(null); + + const mediaRecorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + const durationIntervalRef = useRef(null); + const sessionIdRef = useRef(null); + const meetingIdRef = useRef(null); + const chunkIndexRef = useRef(0); + + useEffect(() => { + return () => { + // 清理资源 + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + if (durationIntervalRef.current) { + clearInterval(durationIntervalRef.current); + } + }; + }, []); + + const getSupportedMimeType = () => { + const types = [ + 'audio/mp4', // 优先使用 mp4 格式 + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus' + ]; + for (const type of types) { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } + return ''; + }; + + const formatDuration = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const handleStartRecording = async (source = 'microphone') => { + try { + setStatus('requesting-permission'); + setAudioSource(source); + + let stream; + + // 1. 根据音频源类型请求权限 + if (source === 'system') { + // 录制系统音频(需要屏幕共享) + try { + // getDisplayMedia 必须包含 video,即使我们只需要音频 + const displayStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, // 必须为 true,否则会报错 + audio: { + echoCancellation: false, // 系统音频不需要回声消除 + noiseSuppression: false, + autoGainControl: false + } + }); + + // 检查是否成功获取音频轨道 + const audioTracks = displayStream.getAudioTracks(); + if (audioTracks.length === 0) { + // 用户没有勾选"共享音频" + displayStream.getTracks().forEach(track => track.stop()); + setToast({ + message: '请在屏幕共享时勾选"共享音频"选项', + type: 'error' + }); + setStatus('idle'); + setAudioSource(null); + return; + } + + // 停止视频轨道,只保留音频 + displayStream.getVideoTracks().forEach(track => track.stop()); + + // 创建只包含音频的新 stream + stream = new MediaStream(audioTracks); + + } catch (err) { + // 如果用户取消屏幕共享 + if (err.name === 'NotAllowedError' || err.name === 'AbortError') { + setToast({ + message: '已取消屏幕共享', + type: 'error' + }); + setStatus('idle'); + setAudioSource(null); + return; + } else if (err.name === 'NotSupportedError') { + setToast({ + message: '您的浏览器不支持系统音频录制,请使用 Chrome 或 Edge 浏览器', + type: 'error' + }); + setStatus('idle'); + setAudioSource(null); + return; + } + throw err; + } + } else { + // 录制麦克风音频 + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 44100 + } + }); + } + + streamRef.current = stream; + setStatus('recording'); + + // 2. 创建会议 + const user = authService.getLocalUser(); + const meetingResponse = await meetingService.createMeeting({ + title: `录音会议-${new Date().toLocaleString('zh-CN')}`, + meeting_time: new Date().toISOString(), + user_id: user.user_id, + attendee_ids: [user.user_id], + tags: '客户端快速会议' + }); + + meetingIdRef.current = meetingResponse.data.meeting_id; + console.log('Meeting created:', meetingIdRef.current); + + // 3. 创建MediaRecorder + const mimeType = getSupportedMimeType(); + if (!mimeType) { + throw new Error('浏览器不支持录音功能'); + } + + console.log('Using MIME type:', mimeType); + + // 4. 初始化上传会话 + const sessionResponse = await audioService.initUploadSession( + meetingIdRef.current, + mimeType + ); + + sessionIdRef.current = sessionResponse.data.session_id; + console.log('Upload session initialized:', sessionIdRef.current); + + // 5. 创建并启动MediaRecorder + const mediaRecorder = new MediaRecorder(stream, { + mimeType, + audioBitsPerSecond: 128000 + }); + + mediaRecorder.ondataavailable = async (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + + // 实时上传分片 + try { + await audioService.uploadChunk( + sessionIdRef.current, + chunkIndexRef.current, + event.data + ); + console.log(`Chunk ${chunkIndexRef.current} uploaded`); + chunkIndexRef.current++; + } catch (err) { + console.error('Error uploading chunk:', err); + // 继续录音,不中断 + } + } + }; + + mediaRecorder.start( + Number(import.meta.env.VITE_AUDIO_CHUNK_INTERVAL) || 10000 + ); // 从配置文件读取分片上传间隔,默认10秒 + mediaRecorderRef.current = mediaRecorder; + setIsRecording(true); + + // 6. 启动计时器 + durationIntervalRef.current = setInterval(() => { + setDuration(prev => prev + 1); + }, 1000); + + } catch (err) { + console.error('Start recording error:', err); + let errorMessage = '启动录音失败'; + let showPermissionHelp = false; + + if (err.name === 'NotAllowedError') { + errorMessage = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风'; + showPermissionHelp = true; + } else if (err.name === 'NotFoundError') { + errorMessage = '未检测到麦克风设备'; + } else if (err.name === 'NotReadableError') { + errorMessage = '麦克风被其他应用占用'; + } else if (err.message) { + errorMessage = err.message; + } + + setToast({ message: errorMessage, type: 'error' }); + setStatus('idle'); + setAudioSource(null); + + // 清理资源 + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + } + }; + + const handleStopRecording = async () => { + if (!mediaRecorderRef.current || !isRecording) return; + + setIsRecording(false); + setStatus('uploading'); + + // 停止计时 + if (durationIntervalRef.current) { + clearInterval(durationIntervalRef.current); + } + + // 停止录音 + mediaRecorderRef.current.stop(); + + // 停止麦克风 + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + + // 等待最后的数据 + mediaRecorderRef.current.onstop = async () => { + try { + // 完成上传 + const mimeType = getSupportedMimeType(); + await audioService.completeUpload( + sessionIdRef.current, + meetingIdRef.current, + chunkIndexRef.current, + mimeType + ); + + console.log('Upload completed successfully'); + setStatus('completed'); + setToast({ message: '录音完成!', type: 'success' }); + + // 跳转到会议详情页 + setTimeout(() => { + navigate('/meetings'); + }, 2000); + + } catch (err) { + console.error('Complete upload error:', err); + setToast({ + message: '上传完成失败: ' + (err.response?.data?.message || err.message), + type: 'error' + }); + setStatus('idle'); + setAudioSource(null); + } + }; + }; + + return ( +
+ {toast && ( + setToast(null)} + /> + )} + +
+ +

会议录音

+
+ +
+
+ {/* 提示文字 */} +
+ {status === 'idle' &&

选择录音方式

} + {status === 'requesting-permission' && ( + <> +

正在请求{audioSource === 'system' ? '屏幕共享' : '麦克风'}权限

+

请在浏览器弹窗中允许{audioSource === 'system' ? '共享屏幕音频' : '使用麦克风'}

+ + )} + {status === 'recording' &&

正在录音中...

} + {status === 'uploading' &&

正在上传处理...

} + {status === 'completed' &&

录音完成!

} +
+ + {/* 中心按钮区域 */} +
+ {/* 空闲状态 - 两个音频源选择按钮 */} + {status === 'idle' && ( +
+ + +
+ )} + + {/* 请求权限 - 加载动画 */} + {status === 'requesting-permission' && ( +
+ +
+ )} + + {/* 录音中 - 停止按钮 + 波纹 */} + {status === 'recording' && ( +
+
+
+
+ +
+ )} + + {/* 上传中 - 加载动画 */} + {status === 'uploading' && ( +
+ +
+ )} + + {/* 完成 - 成功图标 */} + {status === 'completed' && ( +
+ +
+ )} +
+ + {/* 时长显示 - 始终占位,保持高度一致 */} +
+ {(status === 'recording' || status === 'uploading') ? formatDuration(duration) : '\u00A0'} +
+
+
+
+ ); +} + +export default Record; diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..951a5b5 --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,54 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 30000, +}); + +// 请求拦截器:添加token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// 响应拦截器:处理统一格式 +api.interceptors.response.use( + (response) => { + // 后端返回格式: { code, message, data } + // 统一提取 data 字段 + if (response.data && response.data.data !== undefined) { + // 如果是标准格式,检查 code + const code = response.data.code; + if (code !== "200") { + // 如果 code 不是 200,抛出错误 + return Promise.reject({ + response: { + data: { + message: response.data.message || '请求失败' + } + } + }); + } + // 返回完整的 response.data,保留 code, message, data + return response.data; + } + // 如果不是标准格式,直接返回 + return response; + }, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default api; diff --git a/src/services/audio.js b/src/services/audio.js new file mode 100644 index 0000000..c845c52 --- /dev/null +++ b/src/services/audio.js @@ -0,0 +1,44 @@ +import api from './api'; + +export const audioService = { + // 初始化上传会话 + initUploadSession: async (meetingId, mimeType) => { + return await api.post('/audio/stream/init', { + meeting_id: meetingId, + mime_type: mimeType + }); + }, + + // 上传音频分片 + uploadChunk: async (sessionId, chunkIndex, chunk) => { + const formData = new FormData(); + formData.append('session_id', sessionId); + formData.append('chunk_index', chunkIndex); + formData.append('chunk', chunk); + + return await api.post('/audio/stream/chunk', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + }, + + // 完成上传 + completeUpload: async (sessionId, meetingId, totalChunks, mimeType) => { + return await api.post('/audio/stream/complete', { + session_id: sessionId, + meeting_id: meetingId, + total_chunks: totalChunks, + mime_type: mimeType, + auto_transcribe: true, + auto_summarize: true + }); + }, + + // 取消上传 + cancelUpload: async (sessionId) => { + return await api.delete('/audio/stream/cancel', { + data: { session_id: sessionId } + }); + } +}; diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 0000000..5176b92 --- /dev/null +++ b/src/services/auth.js @@ -0,0 +1,54 @@ +import api from './api'; + +export const authService = { + // 登录 + login: async (username, password) => { + const response = await api.post('/auth/login', { username, password }); + // 响应格式: { code, message, data: { user_id, username, caption, email, token, role_id } } + if (response.data?.token) { + localStorage.setItem('token', response.data.token); + // 保存用户信息,不包括 token + const { token, ...userInfo } = response.data; + localStorage.setItem('user', JSON.stringify(userInfo)); + } + return response; + }, + + // 获取当前用户 + getCurrentUser: async () => { + return await api.get('/auth/me'); + }, + + // 退出登录 + logout: () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + }, + + // 检查是否已登录 + isAuthenticated: () => { + return !!localStorage.getItem('token'); + }, + + // 获取本地用户信息 + getLocalUser: () => { + const userStr = localStorage.getItem('user'); + // 防止解析 "undefined" 或 "null" 字符串 + if (!userStr || userStr === 'undefined' || userStr === 'null') { + return null; + } + try { + return JSON.parse(userStr); + } catch (error) { + console.error('Failed to parse user data:', error); + return null; + } + }, + + // 获取用户统计信息 + getUserStats: async (userId) => { + return await api.get('/meetings/stats', { + params: { user_id: userId } + }); + } +}; diff --git a/src/services/meeting.js b/src/services/meeting.js new file mode 100644 index 0000000..e956b31 --- /dev/null +++ b/src/services/meeting.js @@ -0,0 +1,29 @@ +import api from './api'; + +export const meetingService = { + // 创建会议 + createMeeting: async (data) => { + return await api.post('/meetings', { + user_id: data.user_id, + title: data.title || `临时会议-${new Date().getTime()}`, + meeting_time: data.meeting_time || new Date().toISOString(), + attendee_ids: data.attendee_ids || [], + tags: data.tags || '' + }); + }, + + // 获取会议列表 + getMeetings: async (params) => { + return await api.get('/meetings', { params }); + }, + + // 获取会议详情 + getMeetingDetail: async (meetingId) => { + return await api.get(`/meetings/${meetingId}`); + }, + + // 删除会议 + deleteMeeting: async (meetingId) => { + return await api.delete(`/meetings/${meetingId}`); + } +}; diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..01f279c --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,46 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-orange: #FF8C42; + --primary-orange-dark: #FF6B35; + --inactive-gray: #E0E0E0; + --text-dark: #333; + --text-gray: #666; + --text-light: #999; + --white: #FFFFFF; + --shadow: rgba(0, 0, 0, 0.1); + --shadow-hover: rgba(0, 0, 0, 0.2); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +#root { + min-height: 100vh; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +button { + border: none; + cursor: pointer; + font-family: inherit; +} + +input { + font-family: inherit; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..851e48e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 3002, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..6fc33f2 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,921 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.2": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" + integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== + +"@babel/core@^7.28.0": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" + integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.4" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.28.5": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.28.3": + version "7.28.3" + resolved "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" + integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.28.3" + +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.4": + version "7.28.4" + resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" + integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.5" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@remix-run/router@1.23.1": + version "1.23.1" + resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.1.tgz#0ce8857b024e24fc427585316383ad9d295b3a7f" + integrity sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ== + +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + +"@rollup/rollup-darwin-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" + integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== + +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.28.0" + resolved "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/prop-types@*": + version "15.7.15" + resolved "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + +"@types/react-dom@^18.2.17": + version "18.3.7" + resolved "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + +"@types/react@^18.2.43": + version "18.3.27" + resolved "https://registry.npmmirror.com/@types/react/-/react-18.3.27.tgz#74a3b590ea183983dc65a474dc17553ae1415c34" + integrity sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w== + dependencies: + "@types/prop-types" "*" + csstype "^3.2.2" + +"@vitejs/plugin-react@^4.2.1": + version "4.7.0" + resolved "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.6.0: + version "1.13.2" + resolved "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" + integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + +baseline-browser-mapping@^2.8.25: + version "2.8.31" + resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz#16c0f1814638257932e0486dbfdbb3348d0a5710" + integrity sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw== + +browserslist@^4.24.0: + version "4.28.0" + resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.0.tgz#9cefece0a386a17a3cd3d22ebf67b9deca1b5929" + integrity sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ== + dependencies: + baseline-browser-mapping "^2.8.25" + caniuse-lite "^1.0.30001754" + electron-to-chromium "^1.5.249" + node-releases "^2.0.27" + update-browserslist-db "^1.1.4" + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +caniuse-lite@^1.0.30001754: + version "1.0.30001757" + resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz#a46ff91449c69522a462996c6aac4ef95d7ccc5e" + integrity sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +debug@^4.1.0, debug@^4.3.1: + version "4.4.3" + resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +electron-to-chromium@^1.5.249: + version "1.5.260" + resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz#73f555d3e9b9fd16ff48fc406bbad84efa9b86c7" + integrity sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +follow-redirects@^1.15.6: + version "1.15.11" + resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-intrinsic@^1.2.6: + version "1.3.0" + resolved "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lucide-react@^0.292.0: + version "0.292.0" + resolved "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.292.0.tgz#c8a06b2ccd8a348a88669def3c0291c035de2884" + integrity sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.43: + version "8.5.6" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +qrcode.react@^3.1.0: + version "3.2.0" + resolved "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-3.2.0.tgz#97daabd4ff641a3f3c678f87be106ebc55f9cd07" + integrity sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g== + +react-dom@^18.2.0: + version "18.3.1" + resolved "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + +react-router-dom@^6.20.0: + version "6.30.2" + resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.2.tgz#ee8c161bce4890d34484b552f8510f9af0e22b01" + integrity sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q== + dependencies: + "@remix-run/router" "1.23.1" + react-router "6.30.2" + +react-router@6.30.2: + version "6.30.2" + resolved "https://registry.npmmirror.com/react-router/-/react-router-6.30.2.tgz#c78a3b40f7011f49a373b1df89492e7d4ec12359" + integrity sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA== + dependencies: + "@remix-run/router" "1.23.1" + +react@^18.2.0: + version "18.3.1" + resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +rollup@^4.20.0: + version "4.53.3" + resolved "https://registry.npmmirror.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" + integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.53.3" + "@rollup/rollup-android-arm64" "4.53.3" + "@rollup/rollup-darwin-arm64" "4.53.3" + "@rollup/rollup-darwin-x64" "4.53.3" + "@rollup/rollup-freebsd-arm64" "4.53.3" + "@rollup/rollup-freebsd-x64" "4.53.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" + "@rollup/rollup-linux-arm-musleabihf" "4.53.3" + "@rollup/rollup-linux-arm64-gnu" "4.53.3" + "@rollup/rollup-linux-arm64-musl" "4.53.3" + "@rollup/rollup-linux-loong64-gnu" "4.53.3" + "@rollup/rollup-linux-ppc64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-musl" "4.53.3" + "@rollup/rollup-linux-s390x-gnu" "4.53.3" + "@rollup/rollup-linux-x64-gnu" "4.53.3" + "@rollup/rollup-linux-x64-musl" "4.53.3" + "@rollup/rollup-openharmony-arm64" "4.53.3" + "@rollup/rollup-win32-arm64-msvc" "4.53.3" + "@rollup/rollup-win32-ia32-msvc" "4.53.3" + "@rollup/rollup-win32-x64-gnu" "4.53.3" + "@rollup/rollup-win32-x64-msvc" "4.53.3" + fsevents "~2.3.2" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +update-browserslist-db@^1.1.4: + version "1.1.4" + resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz#7802aa2ae91477f255b86e0e46dbc787a206ad4a" + integrity sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +vite@^5.0.8: + version "5.4.21" + resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==