通话逻辑调整
This commit is contained in:
parent
deb2900acc
commit
48d22a1e94
130
BILLING_FEATURES.md
Normal file
130
BILLING_FEATURES.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# 移动端计费功能实现总结
|
||||||
|
|
||||||
|
## 已实现的功能
|
||||||
|
|
||||||
|
### 1. 计费系统核心功能 (`src/types/billing.ts`)
|
||||||
|
- ✅ 用户类型定义:个人用户和企业用户
|
||||||
|
- ✅ 通话类型:语音通话和视频通话
|
||||||
|
- ✅ 翻译类型:文本翻译、手语翻译、人工翻译
|
||||||
|
- ✅ 计费规则配置
|
||||||
|
- ✅ 用户账户信息管理
|
||||||
|
- ✅ 预约信息结构
|
||||||
|
- ✅ 翻译员信息管理
|
||||||
|
- ✅ 通话记录和充值记录
|
||||||
|
|
||||||
|
### 2. 计费服务 (`src/services/billingService.ts`)
|
||||||
|
- ✅ 单例模式的计费服务类
|
||||||
|
- ✅ 根据用户类型设置不同的计费规则
|
||||||
|
- ✅ 通话费用计算(基于通话类型、翻译类型、时长、翻译员费率)
|
||||||
|
- ✅ 余额检查和扣费功能
|
||||||
|
- ✅ 低余额警告
|
||||||
|
- ✅ 账户充值功能
|
||||||
|
- ✅ 金额格式化显示
|
||||||
|
|
||||||
|
### 3. 预约服务 (`src/services/appointmentService.ts`)
|
||||||
|
- ✅ 预约管理服务
|
||||||
|
- ✅ 翻译员管理和可用性检查
|
||||||
|
- ✅ 预约创建、查询、更新、取消
|
||||||
|
- ✅ 时间冲突检查
|
||||||
|
- ✅ 按日期和月份筛选预约
|
||||||
|
- ✅ 模拟数据初始化
|
||||||
|
|
||||||
|
### 4. 移动端首页更新 (`src/pages/mobile/Home.tsx`)
|
||||||
|
- ✅ 用户账户余额显示
|
||||||
|
- ✅ 账户类型显示
|
||||||
|
- ✅ 快捷操作按钮(语音通话、视频通话、预约通话、账户充值)
|
||||||
|
- ✅ 即将到来的预约预览
|
||||||
|
- ✅ 最近活动记录(包含费用信息)
|
||||||
|
- ✅ 美观的卡片式布局
|
||||||
|
|
||||||
|
### 5. 移动端通话页面 (`src/pages/mobile/Call.tsx`)
|
||||||
|
- ✅ 支持语音和视频通话选择
|
||||||
|
- ✅ 翻译类型选择(文本、手语、人工翻译)
|
||||||
|
- ✅ 实时费用计算和显示
|
||||||
|
- ✅ 账户余额监控
|
||||||
|
- ✅ 通话时长计时
|
||||||
|
- ✅ 翻译员选择(人工翻译时)
|
||||||
|
- ✅ 翻译历史记录
|
||||||
|
- ✅ 自动扣费功能
|
||||||
|
|
||||||
|
### 6. 移动端充值页面 (`src/pages/mobile/Recharge.tsx`)
|
||||||
|
- ✅ 预设充值金额选择
|
||||||
|
- ✅ 自定义充值金额输入
|
||||||
|
- ✅ 充值赠送金额计算
|
||||||
|
- ✅ 多种支付方式支持
|
||||||
|
- ✅ 充值历史记录查看
|
||||||
|
- ✅ 账户余额实时显示
|
||||||
|
- ✅ 充值成功后余额更新
|
||||||
|
|
||||||
|
### 7. 移动端预约页面 (`src/pages/mobile/Appointment.tsx`)
|
||||||
|
- ✅ 通话类型选择(语音/视频)
|
||||||
|
- ✅ 翻译类型选择
|
||||||
|
- ✅ 日期和时间选择(未来7天)
|
||||||
|
- ✅ 语言对选择
|
||||||
|
- ✅ 翻译员选择(基于语言和日期可用性)
|
||||||
|
- ✅ 预估费用计算
|
||||||
|
- ✅ 余额充足性检查
|
||||||
|
- ✅ 预约创建和确认
|
||||||
|
|
||||||
|
### 8. 移动端设置页面 (`src/pages/mobile/Settings.tsx`)
|
||||||
|
- ✅ 用户信息展示(账户类型、余额)
|
||||||
|
- ✅ 账户管理功能
|
||||||
|
- ✅ 通话设置选项
|
||||||
|
- ✅ 翻译设置选项
|
||||||
|
- ✅ 通知设置管理
|
||||||
|
- ✅ 帮助和反馈入口
|
||||||
|
- ✅ 退出登录功能
|
||||||
|
|
||||||
|
### 9. 路由配置更新 (`src/routes/index.tsx`)
|
||||||
|
- ✅ 新增充值页面路由 `/mobile/recharge`
|
||||||
|
- ✅ 新增预约页面路由 `/mobile/appointment`
|
||||||
|
- ✅ 移动端路由完整配置
|
||||||
|
|
||||||
|
## 核心计费逻辑
|
||||||
|
|
||||||
|
### 费率配置
|
||||||
|
- **语音通话 + 文本翻译**: ¥0.50/分钟
|
||||||
|
- **视频通话 + 手语翻译**: ¥1.00/分钟
|
||||||
|
- **视频通话 + 人工翻译**: ¥2.00/分钟 + 翻译员费率
|
||||||
|
|
||||||
|
### 用户类型差异
|
||||||
|
- **个人用户**: 使用标准费率
|
||||||
|
- **企业用户**: 可配置专属费率和信用额度
|
||||||
|
|
||||||
|
### 余额管理
|
||||||
|
- 低余额警告:不足5分钟通话费用时提醒
|
||||||
|
- 最低余额限制:不足1分钟通话费用时禁止通话
|
||||||
|
- 自动扣费:通话结束后自动从账户余额扣除费用
|
||||||
|
|
||||||
|
### 充值优惠
|
||||||
|
- 充值金额越大,赠送比例越高
|
||||||
|
- 支持多种支付方式(微信、支付宝、银行卡等)
|
||||||
|
|
||||||
|
## 技术特性
|
||||||
|
|
||||||
|
- 🎯 **TypeScript 类型安全**: 完整的类型定义确保代码质量
|
||||||
|
- 🏗️ **单例模式**: 服务类使用单例模式确保数据一致性
|
||||||
|
- 💾 **模拟数据**: 完整的模拟数据支持开发和测试
|
||||||
|
- 🎨 **响应式设计**: 适配移动端的美观界面
|
||||||
|
- ⚡ **实时计算**: 费用和余额实时更新
|
||||||
|
- 🔒 **余额保护**: 多重余额检查防止超支
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. **用户注册/登录** → 获得初始账户和余额
|
||||||
|
2. **查看余额** → 在首页或设置页面查看当前余额
|
||||||
|
3. **充值账户** → 选择金额和支付方式进行充值
|
||||||
|
4. **预约通话** → 选择时间、类型、翻译员创建预约
|
||||||
|
5. **开始通话** → 选择通话类型和翻译方式
|
||||||
|
6. **实时计费** → 通话过程中显示累计费用
|
||||||
|
7. **自动扣费** → 通话结束后自动从余额扣除费用
|
||||||
|
8. **查看记录** → 在首页查看通话历史和费用记录
|
||||||
|
|
||||||
|
## 下一步开发建议
|
||||||
|
|
||||||
|
1. **后端集成**: 连接真实的后端API替换模拟数据
|
||||||
|
2. **支付集成**: 集成真实的支付网关
|
||||||
|
3. **推送通知**: 实现余额不足和预约提醒
|
||||||
|
4. **数据持久化**: 实现本地数据存储
|
||||||
|
5. **错误处理**: 完善网络错误和支付失败处理
|
||||||
|
6. **单元测试**: 为核心计费逻辑添加测试用例
|
||||||
247
TWILIO_TEST_GUIDE.md
Normal file
247
TWILIO_TEST_GUIDE.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# Twilio 视频通话服务完整测试指南
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
|
||||||
|
#### 后端服务器
|
||||||
|
```bash
|
||||||
|
# 进入服务器目录
|
||||||
|
cd server
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动服务器
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器将在 `http://localhost:3001` 启动
|
||||||
|
|
||||||
|
#### 前端应用
|
||||||
|
```bash
|
||||||
|
# 在项目根目录
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端应用将在 `http://localhost:5173` 启动
|
||||||
|
|
||||||
|
### 2. Twilio 配置
|
||||||
|
|
||||||
|
在 `server/index.js` 中更新您的 Twilio 凭证:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const TWILIO_CONFIG = {
|
||||||
|
accountSid: 'YOUR_TWILIO_ACCOUNT_SID',
|
||||||
|
apiKey: 'YOUR_TWILIO_API_KEY',
|
||||||
|
apiSecret: 'YOUR_TWILIO_API_SECRET',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
或者设置环境变量:
|
||||||
|
```bash
|
||||||
|
export TWILIO_ACCOUNT_SID=your_account_sid
|
||||||
|
export TWILIO_API_KEY=your_api_key
|
||||||
|
export TWILIO_API_SECRET=your_api_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 测试步骤
|
||||||
|
|
||||||
|
### 步骤 1: 后端 API 测试
|
||||||
|
|
||||||
|
#### 1.1 健康检查
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2024-01-01T00:00:00.000Z",
|
||||||
|
"service": "Twilio Token Server"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 获取访问令牌
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/twilio/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"identity": "test-user",
|
||||||
|
"roomName": "test-room"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImN0eSI6InR3aWxpby1mcGE7dj0xIn0...",
|
||||||
|
"identity": "test-user",
|
||||||
|
"roomName": "test-room"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2: 前端功能测试
|
||||||
|
|
||||||
|
#### 2.1 访问视频通话页面
|
||||||
|
1. 打开浏览器访问 `http://localhost:5173`
|
||||||
|
2. 导航到视频通话相关页面
|
||||||
|
3. 查看设备检测是否正常工作
|
||||||
|
|
||||||
|
#### 2.2 设备测试面板
|
||||||
|
访问 `/device-test` 页面进行设备测试:
|
||||||
|
|
||||||
|
1. **摄像头测试**
|
||||||
|
- 点击"测试摄像头"按钮
|
||||||
|
- 确认能看到视频预览
|
||||||
|
- 检查视频质量
|
||||||
|
|
||||||
|
2. **麦克风测试**
|
||||||
|
- 点击"测试麦克风"按钮
|
||||||
|
- 说话并观察音频指示器
|
||||||
|
- 确认音频输入正常
|
||||||
|
|
||||||
|
3. **扬声器测试**
|
||||||
|
- 点击"测试扬声器"按钮
|
||||||
|
- 确认能听到测试音频
|
||||||
|
|
||||||
|
#### 2.3 视频通话测试
|
||||||
|
|
||||||
|
1. **创建房间**
|
||||||
|
- 输入房间名称
|
||||||
|
- 输入用户身份
|
||||||
|
- 点击"加入房间"
|
||||||
|
|
||||||
|
2. **多用户测试**
|
||||||
|
- 在另一个浏览器标签页或设备上
|
||||||
|
- 使用不同的用户身份加入同一房间
|
||||||
|
- 测试双向视频通话
|
||||||
|
|
||||||
|
## 📱 设备兼容性测试
|
||||||
|
|
||||||
|
### 桌面浏览器
|
||||||
|
- ✅ Chrome (推荐)
|
||||||
|
- ✅ Firefox
|
||||||
|
- ✅ Safari
|
||||||
|
- ⚠️ Edge (部分功能)
|
||||||
|
|
||||||
|
### 移动设备
|
||||||
|
- ✅ iOS Safari
|
||||||
|
- ✅ Android Chrome
|
||||||
|
- ⚠️ 其他移动浏览器
|
||||||
|
|
||||||
|
### 测试检查清单
|
||||||
|
|
||||||
|
#### 基础功能
|
||||||
|
- [ ] 摄像头权限请求
|
||||||
|
- [ ] 麦克风权限请求
|
||||||
|
- [ ] 视频预览显示
|
||||||
|
- [ ] 音频输入检测
|
||||||
|
- [ ] 房间创建和加入
|
||||||
|
|
||||||
|
#### 视频通话功能
|
||||||
|
- [ ] 本地视频显示
|
||||||
|
- [ ] 远程视频接收
|
||||||
|
- [ ] 音频双向通信
|
||||||
|
- [ ] 视频质量自适应
|
||||||
|
- [ ] 网络断线重连
|
||||||
|
|
||||||
|
#### 用户界面
|
||||||
|
- [ ] 控制按钮响应
|
||||||
|
- [ ] 设备切换功能
|
||||||
|
- [ ] 全屏模式
|
||||||
|
- [ ] 静音/取消静音
|
||||||
|
- [ ] 视频开启/关闭
|
||||||
|
|
||||||
|
## 🐛 常见问题排查
|
||||||
|
|
||||||
|
### 问题 1: 无法获取访问令牌
|
||||||
|
**症状**: API 返回 500 错误
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查 Twilio 凭证是否正确
|
||||||
|
2. 确认网络连接正常
|
||||||
|
3. 查看服务器日志
|
||||||
|
|
||||||
|
### 问题 2: 摄像头/麦克风权限被拒绝
|
||||||
|
**症状**: 浏览器显示权限被阻止
|
||||||
|
**解决方案**:
|
||||||
|
1. 在浏览器设置中允许摄像头和麦克风权限
|
||||||
|
2. 使用 HTTPS 连接(生产环境)
|
||||||
|
3. 刷新页面重新请求权限
|
||||||
|
|
||||||
|
### 问题 3: 视频通话连接失败
|
||||||
|
**症状**: 无法看到远程视频
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查防火墙设置
|
||||||
|
2. 确认 STUN/TURN 服务器配置
|
||||||
|
3. 测试网络连接质量
|
||||||
|
|
||||||
|
### 问题 4: 音频质量差或有回音
|
||||||
|
**症状**: 音频断断续续或有回音
|
||||||
|
**解决方案**:
|
||||||
|
1. 使用耳机减少回音
|
||||||
|
2. 调整麦克风音量
|
||||||
|
3. 检查网络带宽
|
||||||
|
|
||||||
|
## 📊 性能监控
|
||||||
|
|
||||||
|
### 关键指标
|
||||||
|
- **连接建立时间**: < 3 秒
|
||||||
|
- **视频延迟**: < 200ms
|
||||||
|
- **音频延迟**: < 150ms
|
||||||
|
- **丢包率**: < 1%
|
||||||
|
|
||||||
|
### 监控工具
|
||||||
|
1. 浏览器开发者工具
|
||||||
|
2. Twilio Insights Dashboard
|
||||||
|
3. 网络质量检测
|
||||||
|
|
||||||
|
## 🔧 高级配置
|
||||||
|
|
||||||
|
### 视频质量设置
|
||||||
|
```javascript
|
||||||
|
const videoConfig = {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
frameRate: { ideal: 30 }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 音频设置
|
||||||
|
```javascript
|
||||||
|
const audioConfig = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如果遇到问题,请:
|
||||||
|
1. 查看浏览器控制台错误信息
|
||||||
|
2. 检查服务器日志
|
||||||
|
3. 参考 Twilio 官方文档
|
||||||
|
4. 联系技术支持团队
|
||||||
|
|
||||||
|
## 🚀 生产环境部署
|
||||||
|
|
||||||
|
### 安全考虑
|
||||||
|
1. 使用 HTTPS
|
||||||
|
2. 实施用户认证
|
||||||
|
3. 设置访问令牌过期时间
|
||||||
|
4. 配置 CORS 策略
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
1. 使用 CDN 加速
|
||||||
|
2. 启用 gzip 压缩
|
||||||
|
3. 配置负载均衡
|
||||||
|
4. 监控服务器性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**: 这是一个测试环境配置,生产环境需要额外的安全和性能优化措施。
|
||||||
420
Twilioapp-admin/package-lock.json
generated
420
Twilioapp-admin/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "twilioapp-admin",
|
"name": "twilioapp-admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.3.0",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
@ -23,6 +24,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.4.0",
|
"react-router-dom": "^6.4.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"recharts": "^3.0.2",
|
||||||
"twilio": "^5.7.1",
|
"twilio": "^5.7.1",
|
||||||
"twilio-video": "^2.31.0",
|
"twilio-video": "^2.31.0",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
@ -112,15 +114,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ant-design/icons": {
|
"node_modules/@ant-design/icons": {
|
||||||
"version": "5.6.1",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.0.0.tgz",
|
||||||
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
|
"integrity": "sha512-o0aCCAlHc1o4CQcapAwWzHeaW2x9F49g7P3IDtvtNXgHowtRWYb7kiubt8sQPFvfVIVU/jLw2hzeSlNt0FU+Uw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.0.0",
|
"@ant-design/colors": "^8.0.0",
|
||||||
"@ant-design/icons-svg": "^4.4.0",
|
"@ant-design/icons-svg": "^4.4.0",
|
||||||
"@babel/runtime": "^7.24.8",
|
"@rc-component/util": "^1.2.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6"
|
||||||
"rc-util": "^5.31.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -135,6 +136,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||||
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
|
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@ant-design/icons/node_modules/@ant-design/colors": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/fast-color": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ant-design/react-slick": {
|
"node_modules/@ant-design/react-slick": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
|
||||||
@ -3141,6 +3158,57 @@
|
|||||||
"react-dom": ">=16.9.0"
|
"react-dom": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rc-component/util": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-AUVu6jO+lWjQnUOOECwu8iR0EdElQgWW5NBv5vP/Uf9dWbAX3udhMutRlkVXjuac2E40ghkFy+ve00mc/3Fymg==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^18.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rc-component/util/node_modules/react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^10.0.3",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||||
@ -3254,6 +3322,16 @@
|
|||||||
"@sinonjs/commons": "^1.7.0"
|
"@sinonjs/commons": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
||||||
|
},
|
||||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||||
@ -3814,6 +3892,60 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "8.56.12",
|
"version": "8.56.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||||
@ -4082,6 +4214,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@ -4752,6 +4889,25 @@
|
|||||||
"react-dom": ">=16.9.0"
|
"react-dom": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/antd/node_modules/@ant-design/icons": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/colors": "^7.0.0",
|
||||||
|
"@ant-design/icons-svg": "^4.4.0",
|
||||||
|
"@babel/runtime": "^7.24.8",
|
||||||
|
"classnames": "^2.2.6",
|
||||||
|
"rc-util": "^5.31.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.0.0",
|
||||||
|
"react-dom": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@ -5782,6 +5938,14 @@
|
|||||||
"wrap-ansi": "^7.0.0"
|
"wrap-ansi": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@ -6468,6 +6632,116 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@ -6560,6 +6834,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
||||||
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="
|
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
|
||||||
@ -7205,6 +7484,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.39.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
|
||||||
|
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ=="
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -9220,6 +9504,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
||||||
@ -14298,6 +14590,28 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||||
@ -14440,6 +14754,46 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/immer": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recursive-readdir": {
|
"node_modules/recursive-readdir": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
|
||||||
@ -14463,6 +14817,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -14613,6 +14980,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||||
|
},
|
||||||
"node_modules/resize-observer-polyfill": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@ -16367,6 +16739,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
|
},
|
||||||
"node_modules/tmpl": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@ -16890,6 +17267,14 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util": {
|
"node_modules/util": {
|
||||||
"version": "0.12.5",
|
"version": "0.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||||
@ -16968,6 +17353,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-hr-time": {
|
"node_modules/w3c-hr-time": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.3.0",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.4.0",
|
"react-router-dom": "^6.4.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"recharts": "^3.0.2",
|
||||||
"twilio": "^5.7.1",
|
"twilio": "^5.7.1",
|
||||||
"twilio-video": "^2.31.0",
|
"twilio-video": "^2.31.0",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
|
|||||||
@ -9,7 +9,10 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
SettingOutlined
|
SettingOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
CalculatorOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
@ -28,6 +31,11 @@ import TranslatorList from './pages/Translators/TranslatorList';
|
|||||||
import PaymentList from './pages/Payments/PaymentList';
|
import PaymentList from './pages/Payments/PaymentList';
|
||||||
import SystemSettings from './pages/Settings/SystemSettings';
|
import SystemSettings from './pages/Settings/SystemSettings';
|
||||||
|
|
||||||
|
// 导入计费管理页面
|
||||||
|
import BillingRules from './pages/Billing/BillingRules';
|
||||||
|
import UserAccounts from './pages/Billing/UserAccounts';
|
||||||
|
import BillingStats from './pages/Billing/BillingStats';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
@ -58,6 +66,15 @@ const AppContent: React.FC = () => {
|
|||||||
case 'payments':
|
case 'payments':
|
||||||
navigate('/payments');
|
navigate('/payments');
|
||||||
break;
|
break;
|
||||||
|
case 'billing-rules':
|
||||||
|
navigate('/billing/rules');
|
||||||
|
break;
|
||||||
|
case 'user-accounts':
|
||||||
|
navigate('/billing/accounts');
|
||||||
|
break;
|
||||||
|
case 'billing-stats':
|
||||||
|
navigate('/billing/stats');
|
||||||
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
navigate('/settings');
|
navigate('/settings');
|
||||||
break;
|
break;
|
||||||
@ -97,6 +114,28 @@ const AppContent: React.FC = () => {
|
|||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
label: '译员管理',
|
label: '译员管理',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'billing',
|
||||||
|
icon: <CalculatorOutlined />,
|
||||||
|
label: '计费管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'billing-rules',
|
||||||
|
icon: <CalculatorOutlined />,
|
||||||
|
label: '计费规则',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-accounts',
|
||||||
|
icon: <WalletOutlined />,
|
||||||
|
label: '用户账户',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'billing-stats',
|
||||||
|
icon: <BarChartOutlined />,
|
||||||
|
label: '计费统计',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'payments',
|
key: 'payments',
|
||||||
icon: <DollarOutlined />,
|
icon: <DollarOutlined />,
|
||||||
@ -156,6 +195,7 @@ const AppContent: React.FC = () => {
|
|||||||
background: '#f0f2f5',
|
background: '#f0f2f5',
|
||||||
minHeight: 'calc(100vh - 64px)'
|
minHeight: 'calc(100vh - 64px)'
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/calls" element={<CallList />} />
|
<Route path="/calls" element={<CallList />} />
|
||||||
@ -167,9 +207,16 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/users" element={<UserList />} />
|
<Route path="/users" element={<UserList />} />
|
||||||
<Route path="/translators" element={<TranslatorList />} />
|
<Route path="/translators" element={<TranslatorList />} />
|
||||||
<Route path="/payments" element={<PaymentList />} />
|
<Route path="/payments" element={<PaymentList />} />
|
||||||
|
|
||||||
|
{/* 计费管理路由 */}
|
||||||
|
<Route path="/billing/rules" element={<BillingRules />} />
|
||||||
|
<Route path="/billing/accounts" element={<UserAccounts />} />
|
||||||
|
<Route path="/billing/stats" element={<BillingStats />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SystemSettings />} />
|
<Route path="/settings" element={<SystemSettings />} />
|
||||||
<Route path="*" element={<Dashboard />} />
|
<Route path="*" element={<Dashboard />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
338
Twilioapp-admin/src/pages/Billing/BillingRules.tsx
Normal file
338
Twilioapp-admin/src/pages/Billing/BillingRules.tsx
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
InputNumber,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { BillingRule, CallType, TranslationType, UserType } from '../../types/billing';
|
||||||
|
import billingService from '../../services/billingService';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const BillingRules: React.FC = () => {
|
||||||
|
const [rules, setRules] = useState<BillingRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<BillingRule | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRules = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const rulesData = await billingService.getBillingRules();
|
||||||
|
setRules(rulesData);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取计费规则失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (rule: BillingRule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
form.setFieldsValue(rule);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await billingService.deleteBillingRule(id);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchRules();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
try {
|
||||||
|
if (editingRule) {
|
||||||
|
await billingService.updateBillingRule(editingRule.id, values);
|
||||||
|
message.success('更新成功');
|
||||||
|
} else {
|
||||||
|
await billingService.createBillingRule(values);
|
||||||
|
message.success('创建成功');
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
fetchRules();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(editingRule ? '更新失败' : '创建失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '规则名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '通话类型',
|
||||||
|
dataIndex: 'callType',
|
||||||
|
key: 'callType',
|
||||||
|
render: (type: CallType) => {
|
||||||
|
const typeMap = {
|
||||||
|
[CallType.VOICE]: '语音通话',
|
||||||
|
[CallType.VIDEO]: '视频通话',
|
||||||
|
};
|
||||||
|
return typeMap[type];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '翻译类型',
|
||||||
|
dataIndex: 'translationType',
|
||||||
|
key: 'translationType',
|
||||||
|
render: (type: TranslationType) => {
|
||||||
|
const typeMap = {
|
||||||
|
[TranslationType.TEXT]: '文字翻译',
|
||||||
|
[TranslationType.SIGN_LANGUAGE]: '手语翻译',
|
||||||
|
[TranslationType.HUMAN_INTERPRETER]: '真人翻译',
|
||||||
|
};
|
||||||
|
return typeMap[type];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户类型',
|
||||||
|
dataIndex: 'userType',
|
||||||
|
key: 'userType',
|
||||||
|
render: (type: UserType) => {
|
||||||
|
const typeMap = {
|
||||||
|
[UserType.INDIVIDUAL]: '普通用户',
|
||||||
|
[UserType.ENTERPRISE]: '企业用户',
|
||||||
|
};
|
||||||
|
return typeMap[type];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '每分钟价格',
|
||||||
|
dataIndex: 'pricePerMinute',
|
||||||
|
key: 'pricePerMinute',
|
||||||
|
render: (price: number) => `¥${(price / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最低收费',
|
||||||
|
dataIndex: 'minimumCharge',
|
||||||
|
key: 'minimumCharge',
|
||||||
|
render: (charge: number) => `¥${(charge / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (isActive: boolean) => (
|
||||||
|
<span style={{ color: isActive ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{isActive ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: BillingRule) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个计费规则吗?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeRules = rules.filter(rule => rule.isActive);
|
||||||
|
const inactiveRules = rules.filter(rule => !rule.isActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="总计费规则" value={rules.length} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="启用规则" value={activeRules.length} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="禁用规则" value={inactiveRules.length} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="计费规则管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||||
|
新增规则
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rules}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingRule ? '编辑计费规则' : '新增计费规则'}
|
||||||
|
open={modalVisible}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="规则名称"
|
||||||
|
rules={[{ required: true, message: '请输入规则名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入规则名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="callType"
|
||||||
|
label="通话类型"
|
||||||
|
rules={[{ required: true, message: '请选择通话类型' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="请选择通话类型">
|
||||||
|
<Option value={CallType.VOICE}>语音通话</Option>
|
||||||
|
<Option value={CallType.VIDEO}>视频通话</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="translationType"
|
||||||
|
label="翻译类型"
|
||||||
|
rules={[{ required: true, message: '请选择翻译类型' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="请选择翻译类型">
|
||||||
|
<Option value={TranslationType.TEXT}>文字翻译</Option>
|
||||||
|
<Option value={TranslationType.SIGN_LANGUAGE}>手语翻译</Option>
|
||||||
|
<Option value={TranslationType.HUMAN_INTERPRETER}>真人翻译</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="userType"
|
||||||
|
label="用户类型"
|
||||||
|
rules={[{ required: true, message: '请选择用户类型' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="请选择用户类型">
|
||||||
|
<Option value={UserType.INDIVIDUAL}>普通用户</Option>
|
||||||
|
<Option value={UserType.ENTERPRISE}>企业用户</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="pricePerMinute"
|
||||||
|
label="每分钟价格(分)"
|
||||||
|
rules={[{ required: true, message: '请输入每分钟价格' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="请输入价格(分)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="分"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="minimumCharge"
|
||||||
|
label="最低收费(分)"
|
||||||
|
rules={[{ required: true, message: '请输入最低收费' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="请输入最低收费(分)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="分"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="isActive"
|
||||||
|
label="状态"
|
||||||
|
valuePropName="checked"
|
||||||
|
initialValue={true}
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
{editingRule ? '更新' : '创建'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setModalVisible(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingRules;
|
||||||
293
Twilioapp-admin/src/pages/Billing/BillingStats.tsx
Normal file
293
Twilioapp-admin/src/pages/Billing/BillingStats.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
DatePicker,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { BillingStats as BillingStatsType } from '../../types/billing';
|
||||||
|
import billingService from '../../services/billingService';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const BillingStats: React.FC = () => {
|
||||||
|
const [stats, setStats] = useState<BillingStatsType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dateRange, setDateRange] = useState<[any, any] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const startDate = dateRange?.[0]?.toDate();
|
||||||
|
const endDate = dateRange?.[1]?.toDate();
|
||||||
|
const statsData = await billingService.getBillingStats(startDate, endDate);
|
||||||
|
setStats(statsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return <div>加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 饼图颜色配置
|
||||||
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||||
|
|
||||||
|
// 服务统计表格列
|
||||||
|
const serviceColumns = [
|
||||||
|
{
|
||||||
|
title: '服务类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '使用次数',
|
||||||
|
dataIndex: 'count',
|
||||||
|
key: 'count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收入金额',
|
||||||
|
dataIndex: 'revenue',
|
||||||
|
key: 'revenue',
|
||||||
|
render: (revenue: number) => `¥${(revenue / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均单价',
|
||||||
|
key: 'avgPrice',
|
||||||
|
render: (_: any, record: any) =>
|
||||||
|
`¥${((record.revenue / record.count) / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 筛选条件 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<span>时间范围:</span>
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={fetchStats} loading={loading}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 核心指标 */}
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总收入"
|
||||||
|
value={stats.totalRevenue / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
valueStyle={{ color: '#3f8600' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总用户数"
|
||||||
|
value={stats.totalUsers}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="活跃用户数"
|
||||||
|
value={stats.activeUsers}
|
||||||
|
valueStyle={{ color: '#722ed1' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="用户活跃率"
|
||||||
|
value={(stats.activeUsers / stats.totalUsers * 100)}
|
||||||
|
precision={1}
|
||||||
|
suffix="%"
|
||||||
|
valueStyle={{ color: '#13c2c2' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总通话数"
|
||||||
|
value={stats.totalCalls}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总预约数"
|
||||||
|
value={stats.totalAppointments}
|
||||||
|
valueStyle={{ color: '#fa8c16' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="平均通话时长"
|
||||||
|
value={stats.averageCallDuration}
|
||||||
|
suffix="分钟"
|
||||||
|
valueStyle={{ color: '#eb2f96' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="平均通话费用"
|
||||||
|
value={stats.averageCallCost / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
valueStyle={{ color: '#f5222d' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 图表区域 */}
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
{/* 收入趋势图 */}
|
||||||
|
<Col span={16}>
|
||||||
|
<Card title="收入趋势">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={stats.revenueByDate}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis tickFormatter={(value) => `¥${(value / 100).toFixed(0)}`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: any) => [`¥${(value / 100).toFixed(2)}`, '收入']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#1890ff"
|
||||||
|
fill="#1890ff"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 服务类型分布 */}
|
||||||
|
<Col span={8}>
|
||||||
|
<Card title="服务类型分布">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={stats.topServices}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ type, percent }) => `${type} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="count"
|
||||||
|
>
|
||||||
|
{stats.topServices.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* 服务收入对比 */}
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="服务收入对比">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={stats.topServices}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="type" />
|
||||||
|
<YAxis tickFormatter={(value) => `¥${(value / 100).toFixed(0)}`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: any) => [`¥${(value / 100).toFixed(2)}`, '收入']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="revenue" fill="#52c41a" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 服务使用次数对比 */}
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="服务使用次数对比">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={stats.topServices}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="type" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip formatter={(value: any) => [value, '次数']} />
|
||||||
|
<Bar dataKey="count" fill="#1890ff" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 详细数据表格 */}
|
||||||
|
<Card title="服务详细统计" style={{ marginTop: 16 }}>
|
||||||
|
<Table
|
||||||
|
columns={serviceColumns}
|
||||||
|
dataSource={stats.topServices}
|
||||||
|
rowKey="type"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingStats;
|
||||||
318
Twilioapp-admin/src/pages/Billing/UserAccounts.tsx
Normal file
318
Twilioapp-admin/src/pages/Billing/UserAccounts.tsx
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Tag,
|
||||||
|
Tabs,
|
||||||
|
} from 'antd';
|
||||||
|
import { WalletOutlined, PlusOutlined, MinusOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
|
import { UserAccount, UserType, RechargeRecord, ConsumptionRecord } from '../../types/billing';
|
||||||
|
import billingService from '../../services/billingService';
|
||||||
|
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
const UserAccounts: React.FC = () => {
|
||||||
|
const [accounts, setAccounts] = useState<UserAccount[]>([]);
|
||||||
|
const [rechargeRecords, setRechargeRecords] = useState<RechargeRecord[]>([]);
|
||||||
|
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [modalType, setModalType] = useState<'recharge' | 'deduct' | 'freeze' | 'unfreeze'>('recharge');
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
fetchRechargeRecords();
|
||||||
|
fetchConsumptionRecords();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { accounts: accountsData } = await billingService.getUserAccounts();
|
||||||
|
setAccounts(accountsData);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取用户账户失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRechargeRecords = async () => {
|
||||||
|
try {
|
||||||
|
const { records } = await billingService.getRechargeRecords();
|
||||||
|
setRechargeRecords(records);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取充值记录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchConsumptionRecords = async () => {
|
||||||
|
try {
|
||||||
|
const { records } = await billingService.getConsumptionRecords();
|
||||||
|
setConsumptionRecords(records);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取消费记录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBalanceOperation = (account: UserAccount, type: 'recharge' | 'deduct' | 'freeze' | 'unfreeze') => {
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setModalType(type);
|
||||||
|
form.resetFields();
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
if (!selectedAccount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { amount, reason } = values;
|
||||||
|
|
||||||
|
switch (modalType) {
|
||||||
|
case 'recharge':
|
||||||
|
await billingService.updateUserBalance(selectedAccount.userId, amount, reason || '管理员充值');
|
||||||
|
message.success('充值成功');
|
||||||
|
break;
|
||||||
|
case 'deduct':
|
||||||
|
await billingService.updateUserBalance(selectedAccount.userId, -amount, reason || '管理员扣费');
|
||||||
|
message.success('扣费成功');
|
||||||
|
break;
|
||||||
|
case 'freeze':
|
||||||
|
await billingService.freezeUserBalance(selectedAccount.userId, amount);
|
||||||
|
message.success('冻结成功');
|
||||||
|
break;
|
||||||
|
case 'unfreeze':
|
||||||
|
await billingService.unfreezeUserBalance(selectedAccount.userId, amount);
|
||||||
|
message.success('解冻成功');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountColumns = [
|
||||||
|
{
|
||||||
|
title: '用户ID',
|
||||||
|
dataIndex: 'userId',
|
||||||
|
key: 'userId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户类型',
|
||||||
|
dataIndex: 'userType',
|
||||||
|
key: 'userType',
|
||||||
|
render: (type: UserType) => {
|
||||||
|
const typeMap = {
|
||||||
|
[UserType.INDIVIDUAL]: { text: '普通用户', color: 'blue' },
|
||||||
|
[UserType.ENTERPRISE]: { text: '企业用户', color: 'gold' },
|
||||||
|
};
|
||||||
|
const config = typeMap[type];
|
||||||
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账户余额',
|
||||||
|
dataIndex: 'balance',
|
||||||
|
key: 'balance',
|
||||||
|
render: (balance: number) => (
|
||||||
|
<span style={{ color: balance > 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
¥{(balance / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '冻结余额',
|
||||||
|
dataIndex: 'frozenBalance',
|
||||||
|
key: 'frozenBalance',
|
||||||
|
render: (balance: number) => `¥${(balance / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '累计充值',
|
||||||
|
dataIndex: 'totalRecharge',
|
||||||
|
key: 'totalRecharge',
|
||||||
|
render: (amount: number) => `¥${(amount / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '累计消费',
|
||||||
|
dataIndex: 'totalConsumption',
|
||||||
|
key: 'totalConsumption',
|
||||||
|
render: (amount: number) => `¥${(amount / 100).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: UserAccount) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleBalanceOperation(record, 'recharge')}
|
||||||
|
>
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<MinusOutlined />}
|
||||||
|
onClick={() => handleBalanceOperation(record, 'deduct')}
|
||||||
|
>
|
||||||
|
扣费
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleBalanceOperation(record, 'freeze')}
|
||||||
|
>
|
||||||
|
冻结
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalBalance = accounts.reduce((sum, account) => sum + account.balance, 0);
|
||||||
|
const totalFrozenBalance = accounts.reduce((sum, account) => sum + account.frozenBalance, 0);
|
||||||
|
|
||||||
|
const getModalTitle = () => {
|
||||||
|
const titles = {
|
||||||
|
recharge: '用户充值',
|
||||||
|
deduct: '用户扣费',
|
||||||
|
freeze: '冻结余额',
|
||||||
|
unfreeze: '解冻余额',
|
||||||
|
};
|
||||||
|
return titles[modalType];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总账户余额"
|
||||||
|
value={totalBalance / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总冻结余额"
|
||||||
|
value={totalFrozenBalance / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="用户数量"
|
||||||
|
value={accounts.length}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Tabs defaultActiveKey="accounts">
|
||||||
|
<TabPane tab="用户账户" key="accounts">
|
||||||
|
<Table
|
||||||
|
columns={accountColumns}
|
||||||
|
dataSource={accounts}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={getModalTitle()}
|
||||||
|
open={modalVisible}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
>
|
||||||
|
<Form.Item label="用户ID">
|
||||||
|
<Input value={selectedAccount?.userId} disabled />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="当前余额">
|
||||||
|
<Input
|
||||||
|
value={`¥${((selectedAccount?.balance || 0) / 100).toFixed(2)}`}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="amount"
|
||||||
|
label="金额(分)"
|
||||||
|
rules={[{ required: true, message: '请输入金额' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
placeholder="请输入金额(分)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="分"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{(modalType === 'recharge' || modalType === 'deduct') && (
|
||||||
|
<Form.Item
|
||||||
|
name="reason"
|
||||||
|
label="操作原因"
|
||||||
|
rules={[{ required: true, message: '请输入操作原因' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="请输入操作原因"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
确认{getModalTitle()}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setModalVisible(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAccounts;
|
||||||
3
Twilioapp-admin/src/pages/Billing/index.ts
Normal file
3
Twilioapp-admin/src/pages/Billing/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as BillingRules } from './BillingRules';
|
||||||
|
export { default as UserAccounts } from './UserAccounts';
|
||||||
|
export { default as BillingStats } from './BillingStats';
|
||||||
440
Twilioapp-admin/src/services/billingService.ts
Normal file
440
Twilioapp-admin/src/services/billingService.ts
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
import {
|
||||||
|
BillingRule,
|
||||||
|
UserAccount,
|
||||||
|
CallRecord,
|
||||||
|
RechargeRecord,
|
||||||
|
ConsumptionRecord,
|
||||||
|
BillingStats,
|
||||||
|
CallType,
|
||||||
|
TranslationType,
|
||||||
|
UserType,
|
||||||
|
BILLING_CONFIG,
|
||||||
|
} from '../types/billing';
|
||||||
|
|
||||||
|
class BillingService {
|
||||||
|
private static instance: BillingService;
|
||||||
|
|
||||||
|
public static getInstance(): BillingService {
|
||||||
|
if (!BillingService.instance) {
|
||||||
|
BillingService.instance = new BillingService();
|
||||||
|
}
|
||||||
|
return BillingService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费规则管理
|
||||||
|
async getBillingRules(): Promise<BillingRule[]> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '语音文字翻译',
|
||||||
|
callType: CallType.VOICE,
|
||||||
|
translationType: TranslationType.TEXT,
|
||||||
|
pricePerMinute: 50,
|
||||||
|
minimumCharge: 50,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '视频手语翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.SIGN_LANGUAGE,
|
||||||
|
pricePerMinute: 100,
|
||||||
|
minimumCharge: 100,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '视频真人翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.HUMAN_INTERPRETER,
|
||||||
|
pricePerMinute: 200,
|
||||||
|
minimumCharge: 200,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingRule(rule: Omit<BillingRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<BillingRule> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const newRule: BillingRule = {
|
||||||
|
...rule,
|
||||||
|
id: Date.now().toString(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
return newRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBillingRule(id: string, updates: Partial<BillingRule>): Promise<BillingRule> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const existingRule = await this.getBillingRuleById(id);
|
||||||
|
return {
|
||||||
|
...existingRule,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBillingRule(id: string): Promise<void> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
console.log('删除计费规则:', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBillingRuleById(id: string): Promise<BillingRule> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const rules = await this.getBillingRules();
|
||||||
|
const rule = rules.find(r => r.id === id);
|
||||||
|
if (!rule) {
|
||||||
|
throw new Error('计费规则未找到');
|
||||||
|
}
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户账户管理
|
||||||
|
async getUserAccounts(page: number = 1, pageSize: number = 10): Promise<{
|
||||||
|
accounts: UserAccount[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const mockAccounts: UserAccount[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: 'user1',
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
balance: 5000,
|
||||||
|
frozenBalance: 0,
|
||||||
|
totalRecharge: 10000,
|
||||||
|
totalConsumption: 5000,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: 'user2',
|
||||||
|
userType: UserType.ENTERPRISE,
|
||||||
|
balance: 50000,
|
||||||
|
frozenBalance: 5000,
|
||||||
|
creditLimit: 100000,
|
||||||
|
totalRecharge: 100000,
|
||||||
|
totalConsumption: 50000,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: mockAccounts,
|
||||||
|
total: mockAccounts.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserAccountById(userId: string): Promise<UserAccount> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const { accounts } = await this.getUserAccounts();
|
||||||
|
const account = accounts.find(a => a.userId === userId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('用户账户未找到');
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserBalance(userId: string, amount: number, reason: string): Promise<UserAccount> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const account = await this.getUserAccountById(userId);
|
||||||
|
account.balance += amount;
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
|
||||||
|
// 记录消费记录
|
||||||
|
if (amount !== 0) {
|
||||||
|
await this.createConsumptionRecord({
|
||||||
|
userId,
|
||||||
|
relatedType: 'call',
|
||||||
|
relatedId: 'admin-adjustment',
|
||||||
|
amount: Math.abs(amount),
|
||||||
|
balanceBefore: account.balance - amount,
|
||||||
|
balanceAfter: account.balance,
|
||||||
|
description: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async freezeUserBalance(userId: string, amount: number): Promise<UserAccount> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const account = await this.getUserAccountById(userId);
|
||||||
|
if (account.balance < amount) {
|
||||||
|
throw new Error('余额不足');
|
||||||
|
}
|
||||||
|
account.balance -= amount;
|
||||||
|
account.frozenBalance += amount;
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async unfreezeUserBalance(userId: string, amount: number): Promise<UserAccount> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const account = await this.getUserAccountById(userId);
|
||||||
|
if (account.frozenBalance < amount) {
|
||||||
|
throw new Error('冻结余额不足');
|
||||||
|
}
|
||||||
|
account.frozenBalance -= amount;
|
||||||
|
account.balance += amount;
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话记录管理
|
||||||
|
async getCallRecords(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filters?: {
|
||||||
|
userId?: string;
|
||||||
|
status?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
records: CallRecord[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const mockRecords: CallRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: 'user1',
|
||||||
|
callType: CallType.VOICE,
|
||||||
|
translationType: TranslationType.TEXT,
|
||||||
|
startTime: new Date(Date.now() - 3600000),
|
||||||
|
endTime: new Date(),
|
||||||
|
duration: 60,
|
||||||
|
cost: 3000,
|
||||||
|
status: 'completed',
|
||||||
|
billingDetails: {
|
||||||
|
baseRate: 50,
|
||||||
|
totalMinutes: 60,
|
||||||
|
totalCost: 3000,
|
||||||
|
},
|
||||||
|
createdAt: new Date(),
|
||||||
|
qualityScore: 4.5,
|
||||||
|
userRating: 5,
|
||||||
|
userFeedback: '翻译质量很好',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: mockRecords,
|
||||||
|
total: mockRecords.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCallRecordById(id: string): Promise<CallRecord> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const { records } = await this.getCallRecords();
|
||||||
|
const record = records.find(r => r.id === id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('通话记录未找到');
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值记录管理
|
||||||
|
async getRechargeRecords(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filters?: {
|
||||||
|
userId?: string;
|
||||||
|
status?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
records: RechargeRecord[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const mockRecords: RechargeRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: 'user1',
|
||||||
|
amount: 10000,
|
||||||
|
bonus: 500,
|
||||||
|
paymentMethod: 'wechat',
|
||||||
|
status: 'completed',
|
||||||
|
transactionId: 'tx_123456',
|
||||||
|
createdAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: mockRecords,
|
||||||
|
total: mockRecords.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async processRecharge(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
paymentMethod: string,
|
||||||
|
transactionId: string
|
||||||
|
): Promise<RechargeRecord> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const bonus = this.calculateRechargeBonus(amount);
|
||||||
|
|
||||||
|
const record: RechargeRecord = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
bonus,
|
||||||
|
paymentMethod,
|
||||||
|
status: 'completed',
|
||||||
|
transactionId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
completedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新用户余额
|
||||||
|
await this.updateUserBalance(userId, amount + bonus, `充值 ${amount/100}元,赠送 ${bonus/100}元`);
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费记录管理
|
||||||
|
async getConsumptionRecords(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filters?: {
|
||||||
|
userId?: string;
|
||||||
|
relatedType?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
records: ConsumptionRecord[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
const mockRecords: ConsumptionRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: 'user1',
|
||||||
|
relatedType: 'call',
|
||||||
|
relatedId: 'call_123',
|
||||||
|
amount: 3000,
|
||||||
|
balanceBefore: 8000,
|
||||||
|
balanceAfter: 5000,
|
||||||
|
description: '语音文字翻译通话费用',
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: mockRecords,
|
||||||
|
total: mockRecords.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConsumptionRecord(
|
||||||
|
record: Omit<ConsumptionRecord, 'id' | 'createdAt'>
|
||||||
|
): Promise<ConsumptionRecord> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
id: Date.now().toString(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
async getBillingStats(
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
): Promise<BillingStats> {
|
||||||
|
// TODO: 替换为实际API调用
|
||||||
|
return {
|
||||||
|
totalRevenue: 1000000, // 10000元
|
||||||
|
totalUsers: 1500,
|
||||||
|
activeUsers: 800,
|
||||||
|
totalCalls: 5000,
|
||||||
|
totalAppointments: 1200,
|
||||||
|
averageCallDuration: 45,
|
||||||
|
averageCallCost: 2000, // 20元
|
||||||
|
topServices: [
|
||||||
|
{ type: '语音文字翻译', count: 3000, revenue: 600000 },
|
||||||
|
{ type: '视频手语翻译', count: 1500, revenue: 300000 },
|
||||||
|
{ type: '视频真人翻译', count: 500, revenue: 100000 },
|
||||||
|
],
|
||||||
|
revenueByDate: [
|
||||||
|
{ date: '2024-01-01', revenue: 50000 },
|
||||||
|
{ date: '2024-01-02', revenue: 60000 },
|
||||||
|
{ date: '2024-01-03', revenue: 55000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具方法
|
||||||
|
private calculateRechargeBonus(amount: number): number {
|
||||||
|
for (const rule of BILLING_CONFIG.RECHARGE_BONUS_RULES) {
|
||||||
|
if (amount >= rule.minAmount) {
|
||||||
|
return Math.floor(amount * rule.bonusPercentage / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算通话费用
|
||||||
|
calculateCallCost(
|
||||||
|
callType: CallType,
|
||||||
|
translationType: TranslationType,
|
||||||
|
duration: number,
|
||||||
|
userType: UserType = UserType.INDIVIDUAL
|
||||||
|
): number {
|
||||||
|
// TODO: 根据计费规则计算实际费用
|
||||||
|
const baseRate = BILLING_CONFIG.DEFAULT_RATES[callType]?.[translationType] || 100;
|
||||||
|
return Math.max(baseRate, baseRate * Math.ceil(duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户余额是否充足
|
||||||
|
async checkUserBalance(userId: string, requiredAmount: number): Promise<boolean> {
|
||||||
|
const account = await this.getUserAccountById(userId);
|
||||||
|
return account.balance >= requiredAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣费
|
||||||
|
async deductBalance(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
relatedType: 'call' | 'appointment' | 'document',
|
||||||
|
relatedId: string,
|
||||||
|
description: string
|
||||||
|
): Promise<void> {
|
||||||
|
const account = await this.getUserAccountById(userId);
|
||||||
|
if (account.balance < amount) {
|
||||||
|
throw new Error('余额不足');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateUserBalance(userId, -amount, description);
|
||||||
|
await this.createConsumptionRecord({
|
||||||
|
userId,
|
||||||
|
relatedType,
|
||||||
|
relatedId,
|
||||||
|
amount,
|
||||||
|
balanceBefore: account.balance,
|
||||||
|
balanceAfter: account.balance - amount,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingService.getInstance();
|
||||||
189
Twilioapp-admin/src/types/billing.ts
Normal file
189
Twilioapp-admin/src/types/billing.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// 用户类型
|
||||||
|
export enum UserType {
|
||||||
|
INDIVIDUAL = 'individual', // 普通用户
|
||||||
|
ENTERPRISE = 'enterprise', // 企业用户
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话类型
|
||||||
|
export enum CallType {
|
||||||
|
VOICE = 'voice', // 语音通话
|
||||||
|
VIDEO = 'video', // 视频通话
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译类型
|
||||||
|
export enum TranslationType {
|
||||||
|
TEXT = 'text', // 文字翻译
|
||||||
|
SIGN_LANGUAGE = 'sign_language', // 手语翻译
|
||||||
|
HUMAN_INTERPRETER = 'human_interpreter', // 真人翻译
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费规则
|
||||||
|
export interface BillingRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
pricePerMinute: number; // 每分钟价格(分)
|
||||||
|
minimumCharge: number; // 最低收费(分)
|
||||||
|
userType: UserType;
|
||||||
|
isActive: boolean; // 是否启用
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户账户信息
|
||||||
|
export interface UserAccount {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userType: UserType;
|
||||||
|
balance: number; // 余额(分)
|
||||||
|
frozenBalance: number; // 冻结余额(分)
|
||||||
|
enterpriseContractId?: string; // 企业合同ID
|
||||||
|
creditLimit?: number; // 信用额度(分)
|
||||||
|
totalRecharge: number; // 累计充值(分)
|
||||||
|
totalConsumption: number; // 累计消费(分)
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预约信息
|
||||||
|
export interface Appointment {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
scheduledTime: Date;
|
||||||
|
duration: number; // 预计时长(分钟)
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
interpreterIds?: string[]; // 翻译员ID列表
|
||||||
|
estimatedCost: number; // 预估费用(分)
|
||||||
|
actualCost?: number; // 实际费用(分)
|
||||||
|
status: 'scheduled' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
// 管理员字段
|
||||||
|
adminNotes?: string;
|
||||||
|
cancelReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译员信息
|
||||||
|
export interface Interpreter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
languages: string[]; // 支持的语言
|
||||||
|
specialties: string[]; // 专业领域
|
||||||
|
rating: number; // 评分
|
||||||
|
pricePerMinute: number; // 每分钟价格(分)
|
||||||
|
availability: {
|
||||||
|
[key: string]: boolean; // 日期可用性
|
||||||
|
};
|
||||||
|
isOnline: boolean;
|
||||||
|
totalCalls: number; // 总通话次数
|
||||||
|
totalEarnings: number; // 总收入(分)
|
||||||
|
status: 'active' | 'inactive' | 'suspended';
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话记录
|
||||||
|
export interface CallRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
appointmentId?: string;
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
interpreterIds?: string[];
|
||||||
|
startTime: Date;
|
||||||
|
endTime?: Date;
|
||||||
|
duration: number; // 实际时长(分钟)
|
||||||
|
cost: number; // 实际费用(分)
|
||||||
|
status: 'in_progress' | 'completed' | 'failed';
|
||||||
|
billingDetails: {
|
||||||
|
baseRate: number;
|
||||||
|
interpreterRate?: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
// 管理员字段
|
||||||
|
adminNotes?: string;
|
||||||
|
qualityScore?: number;
|
||||||
|
userRating?: number;
|
||||||
|
userFeedback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值记录
|
||||||
|
export interface RechargeRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number; // 充值金额(分)
|
||||||
|
bonus: number; // 赠送金额(分)
|
||||||
|
paymentMethod: string;
|
||||||
|
status: 'pending' | 'completed' | 'failed';
|
||||||
|
transactionId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
// 管理员字段
|
||||||
|
adminNotes?: string;
|
||||||
|
refundAmount?: number;
|
||||||
|
refundReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费记录
|
||||||
|
export interface ConsumptionRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
relatedType: 'call' | 'appointment' | 'document';
|
||||||
|
relatedId: string;
|
||||||
|
amount: number; // 消费金额(分)
|
||||||
|
balanceBefore: number; // 消费前余额(分)
|
||||||
|
balanceAfter: number; // 消费后余额(分)
|
||||||
|
description: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费统计
|
||||||
|
export interface BillingStats {
|
||||||
|
totalRevenue: number; // 总收入(分)
|
||||||
|
totalUsers: number; // 总用户数
|
||||||
|
activeUsers: number; // 活跃用户数
|
||||||
|
totalCalls: number; // 总通话数
|
||||||
|
totalAppointments: number; // 总预约数
|
||||||
|
averageCallDuration: number; // 平均通话时长(分钟)
|
||||||
|
averageCallCost: number; // 平均通话费用(分)
|
||||||
|
topServices: Array<{
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
revenueByDate: Array<{
|
||||||
|
date: string;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费配置
|
||||||
|
export const BILLING_CONFIG = {
|
||||||
|
// 默认计费规则
|
||||||
|
DEFAULT_RATES: {
|
||||||
|
[CallType.VOICE]: {
|
||||||
|
[TranslationType.TEXT]: 50, // 0.5元/分钟
|
||||||
|
},
|
||||||
|
[CallType.VIDEO]: {
|
||||||
|
[TranslationType.SIGN_LANGUAGE]: 100, // 1元/分钟
|
||||||
|
[TranslationType.HUMAN_INTERPRETER]: 200, // 2元/分钟
|
||||||
|
},
|
||||||
|
} as Record<CallType, Partial<Record<TranslationType, number>>>,
|
||||||
|
// 低余额警告阈值(5分钟费用)
|
||||||
|
LOW_BALANCE_THRESHOLD_MINUTES: 5,
|
||||||
|
// 最低余额阈值(1分钟费用)
|
||||||
|
MINIMUM_BALANCE_THRESHOLD_MINUTES: 1,
|
||||||
|
// 充值赠送规则
|
||||||
|
RECHARGE_BONUS_RULES: [
|
||||||
|
{ minAmount: 10000, bonusPercentage: 5 }, // 100元以上送5%
|
||||||
|
{ minAmount: 20000, bonusPercentage: 10 }, // 200元以上送10%
|
||||||
|
{ minAmount: 50000, bonusPercentage: 15 }, // 500元以上送15%
|
||||||
|
],
|
||||||
|
};
|
||||||
99
server/index.js
Normal file
99
server/index.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { AccessToken } = require('twilio').jwt;
|
||||||
|
const { VideoGrant } = AccessToken;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3001;
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Twilio 配置 - 请替换为您的实际凭证
|
||||||
|
const TWILIO_CONFIG = {
|
||||||
|
accountSid: process.env.TWILIO_ACCOUNT_SID || 'AC_YOUR_ACCOUNT_SID',
|
||||||
|
apiKey: process.env.TWILIO_API_KEY || 'SK3b25e00e6914162a7cf829cffc415cb3',
|
||||||
|
apiSecret: process.env.TWILIO_API_SECRET || 'PpGH298dlRgMSeGrexUjw1flczTVIw9H',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成 Twilio Access Token
|
||||||
|
app.post('/api/twilio/token', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { identity, roomName } = req.body;
|
||||||
|
|
||||||
|
if (!identity || !roomName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Identity and roomName are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Access Token
|
||||||
|
const token = new AccessToken(
|
||||||
|
TWILIO_CONFIG.accountSid,
|
||||||
|
TWILIO_CONFIG.apiKey,
|
||||||
|
TWILIO_CONFIG.apiSecret,
|
||||||
|
{ identity: identity }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建 Video Grant
|
||||||
|
const videoGrant = new VideoGrant({
|
||||||
|
room: roomName
|
||||||
|
});
|
||||||
|
|
||||||
|
// 将 grant 添加到 token
|
||||||
|
token.addGrant(videoGrant);
|
||||||
|
|
||||||
|
// 生成 JWT token
|
||||||
|
const jwtToken = token.toJwt();
|
||||||
|
|
||||||
|
console.log(`Generated token for identity: ${identity}, room: ${roomName}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token: jwtToken,
|
||||||
|
identity: identity,
|
||||||
|
roomName: roomName
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token generation error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to generate token',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 健康检查端点
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'Twilio Token Server'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取房间信息(模拟)
|
||||||
|
app.get('/api/twilio/rooms', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
rooms: [
|
||||||
|
{ name: 'test-room', participants: 0 },
|
||||||
|
{ name: 'demo-room', participants: 2 },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Twilio Token Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`📋 Health check: http://localhost:${PORT}/health`);
|
||||||
|
console.log(`🎥 Token endpoint: http://localhost:${PORT}/api/twilio/token`);
|
||||||
|
console.log('');
|
||||||
|
console.log('⚠️ 请确保已设置正确的 Twilio 凭证:');
|
||||||
|
console.log(` TWILIO_ACCOUNT_SID: ${TWILIO_CONFIG.accountSid}`);
|
||||||
|
console.log(` TWILIO_API_KEY: ${TWILIO_CONFIG.apiKey}`);
|
||||||
|
console.log(` TWILIO_API_SECRET: ${TWILIO_CONFIG.apiSecret.substring(0, 4)}...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
1446
server/package-lock.json
generated
Normal file
1446
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "twilio-token-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Twilio Video Token Server for TranslatePro",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"twilio": "^4.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"twilio",
|
||||||
|
"video",
|
||||||
|
"token",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"author": "TranslatePro Team",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import { AppProvider } from '@/store';
|
import { AppProvider } from '@/store';
|
||||||
import AppRoutes from '@/routes';
|
import AppRoutes from '@/routes';
|
||||||
|
import DeviceTestPanel from '@/components/DeviceTestPanel';
|
||||||
import '@/styles/global.css';
|
import '@/styles/global.css';
|
||||||
|
|
||||||
// Ant Design 主题配置
|
// Ant Design 主题配置
|
||||||
@ -25,7 +27,7 @@ const theme = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
locale={zhCN}
|
locale={zhCN}
|
||||||
@ -35,6 +37,7 @@ const App = () => {
|
|||||||
<AppProvider>
|
<AppProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
<DeviceTestPanel />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
|
|||||||
48
src/components/DeviceRedirect.tsx
Normal file
48
src/components/DeviceRedirect.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getRecommendedRoute } from '@/utils/deviceDetection';
|
||||||
|
|
||||||
|
const DeviceRedirect: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const recommendedRoute = getRecommendedRoute();
|
||||||
|
navigate(recommendedRoute, { replace: true });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '48px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
animation: 'spin 2s linear infinite',
|
||||||
|
}}>
|
||||||
|
🔄
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
正在为您准备最佳体验...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceRedirect;
|
||||||
145
src/components/DeviceTestPanel.tsx
Normal file
145
src/components/DeviceTestPanel.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const DeviceTestPanel: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const testRoutes = [
|
||||||
|
{ path: '/mobile/home', label: '移动端首页', icon: '📱' },
|
||||||
|
{ path: '/mobile/call', label: '移动端通话', icon: '📞' },
|
||||||
|
{ path: '/mobile/documents', label: '移动端文档', icon: '📄' },
|
||||||
|
{ path: '/mobile/settings', label: '移动端设置', icon: '⚙️' },
|
||||||
|
{ path: '/dashboard', label: '管理后台', icon: '💻' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
toggle: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: '60px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9998,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
minWidth: '200px',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '12px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
routeButton: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
},
|
||||||
|
routeIcon: {
|
||||||
|
marginRight: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
deviceInfo: {
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentDeviceInfo = () => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
userAgent: userAgent.substring(0, 50) + '...',
|
||||||
|
isMobile,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceInfo = getCurrentDeviceInfo();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return null; // 生产环境不显示测试面板
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
style={styles.toggle}
|
||||||
|
onClick={() => setIsVisible(!isVisible)}
|
||||||
|
>
|
||||||
|
{isVisible ? '关闭' : '测试'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isVisible && (
|
||||||
|
<div style={styles.panel}>
|
||||||
|
<div style={styles.title}>🧪 设备测试面板</div>
|
||||||
|
|
||||||
|
{testRoutes.map((route) => (
|
||||||
|
<button
|
||||||
|
key={route.path}
|
||||||
|
style={styles.routeButton}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(route.path);
|
||||||
|
setIsVisible(false);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f0f8ff';
|
||||||
|
e.currentTarget.style.borderColor = '#1890ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = '#e8e8e8';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.routeIcon}>{route.icon}</span>
|
||||||
|
{route.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={styles.deviceInfo}>
|
||||||
|
<div><strong>设备信息:</strong></div>
|
||||||
|
<div>尺寸: {deviceInfo.width} x {deviceInfo.height}</div>
|
||||||
|
<div>移动设备: {deviceInfo.isMobile ? '是' : '否'}</div>
|
||||||
|
<div>UA: {deviceInfo.userAgent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceTestPanel;
|
||||||
99
src/components/MobileLayout.tsx
Normal file
99
src/components/MobileLayout.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import MobileNavigation from './MobileNavigation';
|
||||||
|
|
||||||
|
interface MobileLayoutProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileLayout: FC<MobileLayoutProps> = ({ children }) => {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const mobileKeywords = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||||
|
const isMobileDevice = mobileKeywords.test(userAgent);
|
||||||
|
const isSmallScreen = window.innerWidth <= 768;
|
||||||
|
setIsMobile(isMobileDevice || isSmallScreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
paddingBottom: isMobile ? '80px' : '0',
|
||||||
|
position: 'relative' as const,
|
||||||
|
},
|
||||||
|
mobileHeader: {
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
color: 'white',
|
||||||
|
padding: '12px 16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
// 如果不是移动设备,显示提示信息
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||||
|
maxWidth: '400px',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ color: '#1890ff', marginBottom: '16px' }}>📱 移动端应用</h2>
|
||||||
|
<p style={{ color: '#666', marginBottom: '20px' }}>
|
||||||
|
此应用专为移动设备设计。请在手机或平板电脑上访问,或调整浏览器窗口大小至移动设备尺寸。
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#999', fontSize: '14px' }}>
|
||||||
|
您也可以使用浏览器的开发者工具切换到移动设备视图。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.mobileHeader}>
|
||||||
|
Twilio 翻译应用
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
{children || <Outlet />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileNavigation />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileLayout;
|
||||||
@ -8,10 +8,10 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ path: '/mobile/home', label: '首页', icon: '🏠' },
|
{ path: '/home', label: '首页', icon: '🏠' },
|
||||||
{ path: '/mobile/call', label: '通话', icon: '📞' },
|
{ path: '/call', label: '通话', icon: '📞' },
|
||||||
{ path: '/mobile/documents', label: '文档', icon: '📄' },
|
{ path: '/documents', label: '文档', icon: '📄' },
|
||||||
{ path: '/mobile/settings', label: '我的', icon: '👤' },
|
{ path: '/settings', label: '我的', icon: '👤' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MobileNavigation: FC = () => {
|
const MobileNavigation: FC = () => {
|
||||||
@ -22,39 +22,7 @@ const MobileNavigation: FC = () => {
|
|||||||
navigate(path);
|
navigate(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const styles = {
|
||||||
<div style={styles.container}>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = location.pathname === item.path;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.path}
|
|
||||||
style={{
|
|
||||||
...styles.navItem,
|
|
||||||
...(isActive ? styles.activeNavItem : {}),
|
|
||||||
}}
|
|
||||||
onClick={() => handleNavigation(item.path)}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
...styles.icon,
|
|
||||||
...(isActive ? styles.activeIcon : {}),
|
|
||||||
}}>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
...styles.label,
|
|
||||||
...(isActive ? styles.activeLabel : {}),
|
|
||||||
}}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
container: {
|
container: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row' as const,
|
flexDirection: 'row' as const,
|
||||||
@ -106,6 +74,38 @@ const styles = {
|
|||||||
color: '#1890ff',
|
color: '#1890ff',
|
||||||
fontWeight: '600' as const,
|
fontWeight: '600' as const,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
style={{
|
||||||
|
...styles.navItem,
|
||||||
|
...(isActive ? styles.activeNavItem : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => handleNavigation(item.path)}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
...styles.icon,
|
||||||
|
...(isActive ? styles.activeIcon : {}),
|
||||||
|
}}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
...styles.label,
|
||||||
|
...(isActive ? styles.activeLabel : {}),
|
||||||
|
}}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MobileNavigation;
|
export default MobileNavigation;
|
||||||
@ -8,10 +8,10 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ path: '/mobile/home', label: '首页', icon: '🏠' },
|
{ path: '/home', label: '首页', icon: '🏠' },
|
||||||
{ path: '/mobile/call', label: '通话', icon: '📞' },
|
{ path: '/call', label: '通话', icon: '📞' },
|
||||||
{ path: '/mobile/documents', label: '文档', icon: '📄' },
|
{ path: '/documents', label: '文档', icon: '📄' },
|
||||||
{ path: '/mobile/settings', label: '我的', icon: '👤' },
|
{ path: '/settings', label: '我的', icon: '👤' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MobileNavigation: FC = () => {
|
const MobileNavigation: FC = () => {
|
||||||
|
|||||||
@ -1,22 +1,29 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Button, Card, Row, Col, Space, Typography, message } from 'antd';
|
import { Button, Card, Row, Col, Space, Typography, message, Badge, Statistic } from 'antd';
|
||||||
import {
|
import {
|
||||||
PhoneOutlined,
|
PhoneOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
AudioMutedOutlined,
|
AudioMutedOutlined,
|
||||||
VideoCameraAddOutlined,
|
VideoCameraAddOutlined,
|
||||||
StopOutlined
|
StopOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
ClockCircleOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Room, RemoteParticipant, LocalParticipant } from 'twilio-video';
|
import { Room, RemoteParticipant, LocalParticipant } from 'twilio-video';
|
||||||
import { twilioService, VideoCallOptions, ParticipantInfo } from '../../services/twilioService';
|
import { twilioService, VideoCallOptions, ParticipantInfo } from '../../services/twilioService';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import { CallType, TranslationType, UserAccount, UserType } from '../../types/billing';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
interface VideoCallProps {
|
interface VideoCallProps {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
identity: string;
|
identity: string;
|
||||||
|
callType?: CallType;
|
||||||
|
translationType?: TranslationType;
|
||||||
onLeave?: () => void;
|
onLeave?: () => void;
|
||||||
|
onBillingUpdate?: (cost: number, duration: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParticipantVideoProps {
|
interface ParticipantVideoProps {
|
||||||
@ -92,7 +99,14 @@ const ParticipantVideo: React.FC<ParticipantVideoProps> = ({ participant, isLoca
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeave }) => {
|
export const VideoCall: React.FC<VideoCallProps> = ({
|
||||||
|
roomName,
|
||||||
|
identity,
|
||||||
|
callType = CallType.VIDEO,
|
||||||
|
translationType = TranslationType.SIGN_LANGUAGE,
|
||||||
|
onLeave,
|
||||||
|
onBillingUpdate
|
||||||
|
}) => {
|
||||||
const [room, setRoom] = useState<Room | null>(null);
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
|
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
@ -100,9 +114,152 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
const [audioEnabled, setAudioEnabled] = useState(true);
|
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||||
const [videoEnabled, setVideoEnabled] = useState(true);
|
const [videoEnabled, setVideoEnabled] = useState(true);
|
||||||
|
|
||||||
|
// 计费相关状态
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [callDuration, setCallDuration] = useState(0);
|
||||||
|
const [currentCost, setCurrentCost] = useState(0);
|
||||||
|
const [billingService] = useState(() => BillingService.getInstance());
|
||||||
|
const [lastBillingMinute, setLastBillingMinute] = useState(0);
|
||||||
|
|
||||||
|
const callStartTime = useRef<Date | null>(null);
|
||||||
|
const durationInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const billingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 初始化用户账户信息
|
||||||
|
useEffect(() => {
|
||||||
|
const initUserAccount = async () => {
|
||||||
|
try {
|
||||||
|
// 模拟用户账户数据
|
||||||
|
const mockAccount: UserAccount = {
|
||||||
|
id: 'user-123',
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
balance: 10000, // 100元
|
||||||
|
};
|
||||||
|
billingService.setUserAccount(mockAccount);
|
||||||
|
setUserAccount(mockAccount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户账户失败:', error);
|
||||||
|
message.error('获取用户账户信息失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initUserAccount();
|
||||||
|
}, [billingService]);
|
||||||
|
|
||||||
|
// 检查余额是否足够
|
||||||
|
const checkBalance = () => {
|
||||||
|
if (!userAccount) return false;
|
||||||
|
|
||||||
|
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||||
|
if (!balanceCheck.sufficient) {
|
||||||
|
message.error(`余额不足,需要至少 ¥${(balanceCheck.requiredAmount / 100).toFixed(2)} 才能开始通话`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||||
|
if (shouldWarn) {
|
||||||
|
message.warning('账户余额较低,请及时充值');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始计费
|
||||||
|
const startBilling = () => {
|
||||||
|
callStartTime.current = new Date();
|
||||||
|
|
||||||
|
// 每秒更新通话时长
|
||||||
|
durationInterval.current = setInterval(() => {
|
||||||
|
if (callStartTime.current) {
|
||||||
|
const duration = Math.floor((Date.now() - callStartTime.current.getTime()) / 1000);
|
||||||
|
setCallDuration(duration);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 每分钟进行计费
|
||||||
|
billingInterval.current = setInterval(() => {
|
||||||
|
if (callStartTime.current) {
|
||||||
|
const currentMinute = Math.floor((Date.now() - callStartTime.current.getTime()) / 60000);
|
||||||
|
if (currentMinute > lastBillingMinute) {
|
||||||
|
performBilling(currentMinute);
|
||||||
|
setLastBillingMinute(currentMinute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000); // 每分钟检查一次
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行计费
|
||||||
|
const performBilling = async (minute: number) => {
|
||||||
|
if (!userAccount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cost = billingService.calculateCallCost(callType, translationType, 1);
|
||||||
|
const newTotalCost = currentCost + cost;
|
||||||
|
|
||||||
|
// 检查余额是否足够继续通话
|
||||||
|
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||||
|
if (!balanceCheck.sufficient) {
|
||||||
|
message.error('余额不足,通话即将结束');
|
||||||
|
setTimeout(() => {
|
||||||
|
handleForceDisconnect();
|
||||||
|
}, 30000); // 30秒后强制断开
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣费
|
||||||
|
const deductSuccess = billingService.deductBalance(cost);
|
||||||
|
if (deductSuccess) {
|
||||||
|
setCurrentCost(newTotalCost);
|
||||||
|
const updatedAccount = billingService.getUserAccount();
|
||||||
|
setUserAccount(updatedAccount);
|
||||||
|
|
||||||
|
// 通知父组件计费更新
|
||||||
|
onBillingUpdate?.(newTotalCost, callDuration);
|
||||||
|
|
||||||
|
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||||
|
if (shouldWarn) {
|
||||||
|
message.warning('账户余额较低,请及时充值');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('扣费失败,通话即将结束');
|
||||||
|
handleForceDisconnect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('计费失败:', error);
|
||||||
|
message.error('计费系统异常');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 强制断开连接(余额不足)
|
||||||
|
const handleForceDisconnect = () => {
|
||||||
|
message.error('余额不足,通话已结束');
|
||||||
|
leaveRoom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止计费
|
||||||
|
const stopBilling = () => {
|
||||||
|
if (durationInterval.current) {
|
||||||
|
clearInterval(durationInterval.current);
|
||||||
|
durationInterval.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingInterval.current) {
|
||||||
|
clearInterval(billingInterval.current);
|
||||||
|
billingInterval.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
callStartTime.current = null;
|
||||||
|
setLastBillingMinute(0);
|
||||||
|
};
|
||||||
|
|
||||||
const connectToRoom = async () => {
|
const connectToRoom = async () => {
|
||||||
if (isConnecting) return;
|
if (isConnecting) return;
|
||||||
|
|
||||||
|
// 检查余额
|
||||||
|
if (!checkBalance()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const options: VideoCallOptions = {
|
const options: VideoCallOptions = {
|
||||||
@ -117,6 +274,7 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
|
||||||
setupRoomEventListeners(connectedRoom);
|
setupRoomEventListeners(connectedRoom);
|
||||||
|
startBilling(); // 开始计费
|
||||||
|
|
||||||
message.success('成功连接到视频通话');
|
message.success('成功连接到视频通话');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -146,6 +304,7 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setParticipants([]);
|
setParticipants([]);
|
||||||
|
stopBilling(); // 停止计费
|
||||||
message.info('已断开视频通话连接');
|
message.info('已断开视频通话连接');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,6 +316,7 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setParticipants([]);
|
setParticipants([]);
|
||||||
|
stopBilling(); // 停止计费
|
||||||
onLeave?.();
|
onLeave?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,22 +330,64 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
setVideoEnabled(newVideoState);
|
setVideoEnabled(newVideoState);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCost = (cents: number): string => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
twilioService.disconnect();
|
twilioService.disconnect();
|
||||||
|
stopBilling();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<Card style={{ width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
<Card style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Title level={4}>视频通话</Title>
|
<Title level={4}>视频通话</Title>
|
||||||
<Text>房间: {roomName}</Text>
|
<Text>房间: {roomName}</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text>身份: {identity}</Text>
|
<Text>身份: {identity}</Text>
|
||||||
|
<br />
|
||||||
|
<Text>通话类型: {callType === CallType.VIDEO ? '视频通话' : '语音通话'}</Text>
|
||||||
|
<br />
|
||||||
|
<Text>翻译类型: {translationType === TranslationType.SIGN_LANGUAGE ? '手语翻译' : translationType === TranslationType.TEXT ? '文字翻译' : '真人翻译'}</Text>
|
||||||
<br /><br />
|
<br /><br />
|
||||||
|
|
||||||
|
{/* 用户账户信息 */}
|
||||||
|
{userAccount && (
|
||||||
|
<Card size="small" style={{ marginBottom: '16px', backgroundColor: '#f8f9fa' }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="账户余额"
|
||||||
|
value={userAccount.balance / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
valueStyle={{ fontSize: '16px' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="预计费率"
|
||||||
|
value={billingService.calculateCallCost(callType, translationType, 1) / 100}
|
||||||
|
precision={2}
|
||||||
|
prefix="¥"
|
||||||
|
suffix="/分钟"
|
||||||
|
valueStyle={{ fontSize: '16px' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
@ -217,6 +419,7 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
loading={isConnecting}
|
loading={isConnecting}
|
||||||
onClick={connectToRoom}
|
onClick={connectToRoom}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
disabled={!userAccount}
|
||||||
>
|
>
|
||||||
{isConnecting ? '连接中...' : '加入通话'}
|
{isConnecting ? '连接中...' : '加入通话'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -230,9 +433,32 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
<div style={{ width: '100%', height: '100vh', padding: '16px' }}>
|
<div style={{ width: '100%', height: '100vh', padding: '16px' }}>
|
||||||
<Card style={{ height: '100%' }}>
|
<Card style={{ height: '100%' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<div style={{ marginBottom: '16px', textAlign: 'center' }}>
|
{/* 通话信息和计费状态 */}
|
||||||
<Title level={4}>视频通话 - {roomName}</Title>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<Text>参与者: {participants.length}</Text>
|
<Row gutter={16} align="middle">
|
||||||
|
<Col span={8}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>视频通话 - {roomName}</Title>
|
||||||
|
<Text type="secondary">参与者: {participants.length}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8} style={{ textAlign: 'center' }}>
|
||||||
|
<Badge status="processing" />
|
||||||
|
<Space>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<Text strong>{formatDuration(callDuration)}</Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={8} style={{ textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<WalletOutlined />
|
||||||
|
<Text strong>¥{formatCost(currentCost)}</Text>
|
||||||
|
{userAccount && (
|
||||||
|
<Text type="secondary">
|
||||||
|
(余额: ¥{formatCost(userAccount.balance)})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
@ -256,37 +482,39 @@ export const VideoCall: React.FC<VideoCallProps> = ({ roomName, identity, onLeav
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '16px' }}>
|
{/* 控制按钮 */}
|
||||||
<Row gutter={16} justify="center">
|
<div style={{ marginTop: '16px', textAlign: 'center' }}>
|
||||||
<Col>
|
<Space size="large">
|
||||||
<Button
|
<Button
|
||||||
type={audioEnabled ? 'primary' : 'default'}
|
type={audioEnabled ? 'default' : 'primary'}
|
||||||
shape="circle"
|
danger={!audioEnabled}
|
||||||
size="large"
|
|
||||||
icon={audioEnabled ? <AudioOutlined /> : <AudioMutedOutlined />}
|
icon={audioEnabled ? <AudioOutlined /> : <AudioMutedOutlined />}
|
||||||
onClick={toggleAudio}
|
onClick={toggleAudio}
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<Button
|
|
||||||
type={videoEnabled ? 'primary' : 'default'}
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
size="large"
|
||||||
|
>
|
||||||
|
{audioEnabled ? '静音' : '取消静音'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={videoEnabled ? 'default' : 'primary'}
|
||||||
|
danger={!videoEnabled}
|
||||||
icon={<VideoCameraOutlined />}
|
icon={<VideoCameraOutlined />}
|
||||||
onClick={toggleVideo}
|
onClick={toggleVideo}
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
danger
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
size="large"
|
||||||
|
>
|
||||||
|
{videoEnabled ? '关闭视频' : '开启视频'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="primary"
|
||||||
icon={<StopOutlined />}
|
icon={<StopOutlined />}
|
||||||
onClick={leaveRoom}
|
onClick={leaveRoom}
|
||||||
/>
|
size="large"
|
||||||
</Col>
|
>
|
||||||
</Row>
|
结束通话
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
349
src/components/VideoCallTest.tsx
Normal file
349
src/components/VideoCallTest.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Button, Card, Input, Space, Typography, message, Row, Col, Badge, Divider } from 'antd';
|
||||||
|
import {
|
||||||
|
PhoneOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
AudioMutedOutlined,
|
||||||
|
VideoCameraAddOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
WifiOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Room, RemoteParticipant, LocalParticipant } from 'twilio-video';
|
||||||
|
import { TwilioService, VideoCallOptions, ParticipantInfo } from '../services/twilioService';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const VideoCallTest: React.FC = () => {
|
||||||
|
const [twilioService] = useState(() => new TwilioService());
|
||||||
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||||
|
const [videoEnabled, setVideoEnabled] = useState(true);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [roomName, setRoomName] = useState('test-room');
|
||||||
|
const [identity, setIdentity] = useState('user-' + Math.floor(Math.random() * 1000));
|
||||||
|
|
||||||
|
// 视频引用
|
||||||
|
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// 连接到房间
|
||||||
|
const connectToRoom = async () => {
|
||||||
|
if (isConnecting || !roomName || !identity) {
|
||||||
|
message.warning('请填写房间名称和用户身份');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
try {
|
||||||
|
const options: VideoCallOptions = {
|
||||||
|
roomName,
|
||||||
|
identity,
|
||||||
|
audio: audioEnabled,
|
||||||
|
video: videoEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('连接参数:', options);
|
||||||
|
const connectedRoom = await twilioService.connectToRoom(options);
|
||||||
|
setRoom(connectedRoom);
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
setupRoomEventListeners(connectedRoom);
|
||||||
|
|
||||||
|
message.success(`成功连接到房间: ${roomName}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('连接失败:', error);
|
||||||
|
message.error(`连接失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置房间事件监听器
|
||||||
|
const setupRoomEventListeners = (room: Room) => {
|
||||||
|
const updateParticipants = () => {
|
||||||
|
const participantList = twilioService.getParticipants();
|
||||||
|
setParticipants(participantList);
|
||||||
|
console.log('参与者更新:', participantList);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 参与者连接
|
||||||
|
room.on('participantConnected', (participant: RemoteParticipant) => {
|
||||||
|
console.log(`参与者加入: ${participant.identity}`);
|
||||||
|
message.info(`${participant.identity} 加入了通话`);
|
||||||
|
updateParticipants();
|
||||||
|
|
||||||
|
// 监听远程视频轨道
|
||||||
|
participant.on('trackSubscribed', (track) => {
|
||||||
|
if (track.kind === 'video' && remoteVideoRef.current) {
|
||||||
|
track.attach(remoteVideoRef.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 参与者断开
|
||||||
|
room.on('participantDisconnected', (participant: RemoteParticipant) => {
|
||||||
|
console.log(`参与者离开: ${participant.identity}`);
|
||||||
|
message.info(`${participant.identity} 离开了通话`);
|
||||||
|
updateParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 房间断开
|
||||||
|
room.on('disconnected', () => {
|
||||||
|
console.log('房间连接断开');
|
||||||
|
setIsConnected(false);
|
||||||
|
setRoom(null);
|
||||||
|
setParticipants([]);
|
||||||
|
message.info('已断开视频通话连接');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本地视频轨道
|
||||||
|
const localParticipant = room.localParticipant;
|
||||||
|
if (localParticipant && localVideoRef.current) {
|
||||||
|
localParticipant.videoTracks.forEach((publication) => {
|
||||||
|
if (publication.track) {
|
||||||
|
publication.track.attach(localVideoRef.current!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParticipants();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 离开房间
|
||||||
|
const leaveRoom = () => {
|
||||||
|
if (room) {
|
||||||
|
twilioService.disconnect();
|
||||||
|
setIsConnected(false);
|
||||||
|
setRoom(null);
|
||||||
|
setParticipants([]);
|
||||||
|
message.info('已离开视频通话');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换音频
|
||||||
|
const toggleAudio = () => {
|
||||||
|
const newAudioEnabled = twilioService.toggleAudio();
|
||||||
|
setAudioEnabled(newAudioEnabled);
|
||||||
|
message.info(newAudioEnabled ? '音频已开启' : '音频已关闭');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换视频
|
||||||
|
const toggleVideo = () => {
|
||||||
|
const newVideoEnabled = twilioService.toggleVideo();
|
||||||
|
setVideoEnabled(newVideoEnabled);
|
||||||
|
message.info(newVideoEnabled ? '视频已开启' : '视频已关闭');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试服务器连接
|
||||||
|
const testServerConnection = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3001/health');
|
||||||
|
const data = await response.json();
|
||||||
|
message.success('Token服务器连接正常');
|
||||||
|
console.log('服务器状态:', data);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Token服务器连接失败,请确保服务器已启动');
|
||||||
|
console.error('服务器连接错误:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 组件挂载时测试服务器连接
|
||||||
|
testServerConnection();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 组件卸载时清理连接
|
||||||
|
if (room) {
|
||||||
|
twilioService.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
},
|
||||||
|
videoContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
videoCard: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: '300px',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
width: '100%',
|
||||||
|
height: '250px',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
controlPanel: {
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
participantList: {
|
||||||
|
marginTop: '20px',
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
marginBottom: '10px',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<Title level={2}>🎥 Twilio 视频通话测试</Title>
|
||||||
|
|
||||||
|
{/* 连接状态 */}
|
||||||
|
<div style={styles.statusBadge}>
|
||||||
|
<Badge
|
||||||
|
status={isConnected ? 'success' : 'default'}
|
||||||
|
text={isConnected ? '已连接' : '未连接'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<WifiOutlined />}
|
||||||
|
onClick={testServerConnection}
|
||||||
|
>
|
||||||
|
测试服务器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频区域 */}
|
||||||
|
<div style={styles.videoContainer}>
|
||||||
|
<Card title="本地视频" style={styles.videoCard}>
|
||||||
|
<video
|
||||||
|
ref={localVideoRef}
|
||||||
|
style={styles.video}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="远程视频" style={styles.videoCard}>
|
||||||
|
<video
|
||||||
|
ref={remoteVideoRef}
|
||||||
|
style={styles.video}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制面板 */}
|
||||||
|
<Card title="控制面板" style={styles.controlPanel}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="房间名称"
|
||||||
|
value={roomName}
|
||||||
|
onChange={(e) => setRoomName(e.target.value)}
|
||||||
|
prefix={<HomeOutlined />}
|
||||||
|
disabled={isConnected}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="用户身份"
|
||||||
|
value={identity}
|
||||||
|
onChange={(e) => setIdentity(e.target.value)}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
disabled={isConnected}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Space wrap>
|
||||||
|
{!isConnected ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<VideoCameraAddOutlined />}
|
||||||
|
loading={isConnecting}
|
||||||
|
onClick={connectToRoom}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{isConnecting ? '连接中...' : '加入通话'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={leaveRoom}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
离开通话
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={audioEnabled ? 'default' : 'primary'}
|
||||||
|
icon={audioEnabled ? <AudioOutlined /> : <AudioMutedOutlined />}
|
||||||
|
onClick={toggleAudio}
|
||||||
|
>
|
||||||
|
{audioEnabled ? '关闭音频' : '开启音频'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={videoEnabled ? 'default' : 'primary'}
|
||||||
|
icon={<VideoCameraOutlined />}
|
||||||
|
onClick={toggleVideo}
|
||||||
|
>
|
||||||
|
{videoEnabled ? '关闭视频' : '开启视频'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 参与者列表 */}
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<Card title="参与者列表" style={styles.participantList}>
|
||||||
|
<Row gutter={[16, 8]}>
|
||||||
|
{participants.map((participant) => (
|
||||||
|
<Col span={8} key={participant.sid}>
|
||||||
|
<Card size="small">
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
<div>
|
||||||
|
<Text strong>{participant.identity}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
{participant.isLocal ? '本地' : '远程'} |
|
||||||
|
音频: {participant.audioEnabled ? '开' : '关'} |
|
||||||
|
视频: {participant.videoEnabled ? '开' : '关'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<Card title="📋 使用说明" size="small">
|
||||||
|
<Text>
|
||||||
|
<strong>测试步骤:</strong><br />
|
||||||
|
1. 确保Token服务器已启动(端口3001)<br />
|
||||||
|
2. 填写房间名称和用户身份<br />
|
||||||
|
3. 点击"加入通话"按钮<br />
|
||||||
|
4. 在另一个浏览器标签页中使用相同房间名称测试多人通话<br />
|
||||||
|
5. 使用音频/视频控制按钮测试功能
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoCallTest;
|
||||||
580
src/pages/mobile/Appointment.tsx
Normal file
580
src/pages/mobile/Appointment.tsx
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { AppointmentService } from '../../services/appointmentService';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import {
|
||||||
|
Appointment,
|
||||||
|
Interpreter,
|
||||||
|
CallType,
|
||||||
|
TranslationType,
|
||||||
|
UserAccount
|
||||||
|
} from '../../types/billing';
|
||||||
|
|
||||||
|
const MobileAppointment: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const appointmentService = AppointmentService.getInstance();
|
||||||
|
const billingService = BillingService.getInstance();
|
||||||
|
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string>('');
|
||||||
|
const [callType, setCallType] = useState<CallType>(CallType.VOICE);
|
||||||
|
const [translationType, setTranslationType] = useState<TranslationType>(TranslationType.TEXT);
|
||||||
|
const [selectedLanguages, setSelectedLanguages] = useState({
|
||||||
|
from: 'zh-CN',
|
||||||
|
to: 'en-US',
|
||||||
|
});
|
||||||
|
const [selectedInterpreter, setSelectedInterpreter] = useState<Interpreter | null>(null);
|
||||||
|
const [availableInterpreters, setAvailableInterpreters] = useState<Interpreter[]>([]);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [estimatedCost, setEstimatedCost] = useState(0);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
|
||||||
|
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
|
||||||
|
{ code: 'ja-JP', name: '日本語', flag: '🇯🇵' },
|
||||||
|
{ code: 'ko-KR', name: '한국어', flag: '🇰🇷' },
|
||||||
|
{ code: 'es-ES', name: 'Español', flag: '🇪🇸' },
|
||||||
|
{ code: 'fr-FR', name: 'Français', flag: '🇫🇷' },
|
||||||
|
{ code: 'de-DE', name: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 生成可选时间段
|
||||||
|
const timeSlots = [
|
||||||
|
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
|
||||||
|
'14:00', '14:30', '15:00', '15:30', '16:00', '16:30',
|
||||||
|
'17:00', '17:30', '18:00', '18:30', '19:00', '19:30',
|
||||||
|
'20:00', '20:30', '21:00'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 生成未来7天的日期选项
|
||||||
|
const getDateOptions = () => {
|
||||||
|
const dates = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + i);
|
||||||
|
dates.push({
|
||||||
|
value: date.toISOString().split('T')[0],
|
||||||
|
label: i === 0 ? '今天' : i === 1 ? '明天' :
|
||||||
|
`${date.getMonth() + 1}月${date.getDate()}日`,
|
||||||
|
weekday: date.toLocaleDateString('zh-CN', { weekday: 'short' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const account = billingService.getUserAccount();
|
||||||
|
setUserAccount(account);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const date = new Date(selectedDate);
|
||||||
|
const interpreters = appointmentService.getAvailableInterpreters(
|
||||||
|
date,
|
||||||
|
[selectedLanguages.from, selectedLanguages.to]
|
||||||
|
);
|
||||||
|
setAvailableInterpreters(interpreters);
|
||||||
|
}
|
||||||
|
}, [selectedDate, selectedLanguages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 计算预估费用
|
||||||
|
if (callType && translationType && userAccount) {
|
||||||
|
const interpreterRate = selectedInterpreter?.pricePerMinute;
|
||||||
|
const baseCost = billingService.calculateCallCost(
|
||||||
|
callType,
|
||||||
|
translationType,
|
||||||
|
30, // 假设30分钟
|
||||||
|
interpreterRate
|
||||||
|
);
|
||||||
|
setEstimatedCost(baseCost);
|
||||||
|
}
|
||||||
|
}, [callType, translationType, selectedInterpreter, userAccount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 根据通话类型设置默认翻译类型
|
||||||
|
if (callType === CallType.VIDEO) {
|
||||||
|
setTranslationType(TranslationType.SIGN_LANGUAGE);
|
||||||
|
} else {
|
||||||
|
setTranslationType(TranslationType.TEXT);
|
||||||
|
}
|
||||||
|
}, [callType]);
|
||||||
|
|
||||||
|
const formatCurrency = (cents: number) => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedDate || !selectedTime || !callType || !translationType) {
|
||||||
|
alert('请完善预约信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userAccount || userAccount.balance < estimatedCost) {
|
||||||
|
alert('账户余额不足,请先充值');
|
||||||
|
navigate('/mobile/recharge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建预约数据
|
||||||
|
const appointmentData = {
|
||||||
|
userId: 'user_1', // 实际应该从用户状态获取
|
||||||
|
title: `${callType === CallType.VOICE ? '语音' : '视频'}通话预约`,
|
||||||
|
description: `${translationType === TranslationType.TEXT ? '文本翻译' :
|
||||||
|
translationType === TranslationType.SIGN_LANGUAGE ? '手语翻译' : '人工翻译'}`,
|
||||||
|
scheduledTime: new Date(`${selectedDate}T${selectedTime}`),
|
||||||
|
duration: 60, // 默认60分钟
|
||||||
|
callType,
|
||||||
|
translationType,
|
||||||
|
interpreterIds: selectedInterpreter ? [selectedInterpreter.id] : undefined,
|
||||||
|
estimatedCost,
|
||||||
|
status: 'scheduled' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建预约
|
||||||
|
const appointment = appointmentService.createAppointment(appointmentData);
|
||||||
|
|
||||||
|
alert('预约创建成功!');
|
||||||
|
navigate('/mobile/home');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建预约失败:', error);
|
||||||
|
alert('创建预约失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100vh',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '12px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
callTypeSelector: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
callTypeButton: (active: boolean) => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: active ? '2px solid #1890ff' : '2px solid #f0f0f0',
|
||||||
|
backgroundColor: active ? '#e6f7ff' : 'white',
|
||||||
|
color: active ? '#1890ff' : '#666',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
}),
|
||||||
|
dateGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
dateOption: (selected: boolean) => ({
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: selected ? '2px solid #1890ff' : '2px solid #f0f0f0',
|
||||||
|
backgroundColor: selected ? '#e6f7ff' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
}),
|
||||||
|
dateLabel: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
dateWeekday: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
timeGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
timeSlot: (selected: boolean, available: boolean) => ({
|
||||||
|
padding: '12px 8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: selected ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||||
|
backgroundColor: selected ? '#e6f7ff' : available ? 'white' : '#f5f5f5',
|
||||||
|
color: selected ? '#1890ff' : available ? '#333' : '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
cursor: available ? 'pointer' : 'not-allowed',
|
||||||
|
}),
|
||||||
|
languageRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
languageSelect: {
|
||||||
|
flex: 1,
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
swapButton: {
|
||||||
|
margin: '0 12px',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#f0f8ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
},
|
||||||
|
translationTypeSelector: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
translationTypeButton: (active: boolean) => ({
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: active ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||||
|
backgroundColor: active ? '#e6f7ff' : 'white',
|
||||||
|
color: active ? '#1890ff' : '#666',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
}),
|
||||||
|
interpreterList: {
|
||||||
|
marginTop: '16px',
|
||||||
|
},
|
||||||
|
interpreterItem: (selected: boolean) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: selected ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||||
|
backgroundColor: selected ? '#e6f7ff' : 'white',
|
||||||
|
marginBottom: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
|
interpreterAvatar: {
|
||||||
|
fontSize: '32px',
|
||||||
|
marginRight: '16px',
|
||||||
|
},
|
||||||
|
interpreterInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
interpreterName: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
interpreterDetails: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
interpreterRate: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#fa8c16',
|
||||||
|
},
|
||||||
|
descriptionInput: {
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '80px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
fontSize: '14px',
|
||||||
|
resize: 'vertical' as const,
|
||||||
|
},
|
||||||
|
costSummary: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
costRow: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
costLabel: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
costValue: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: selectedDate && selectedTime && !isSubmitting ? '#1890ff' : '#d9d9d9',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
cursor: selectedDate && selectedTime && !isSubmitting ? 'pointer' : 'not-allowed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<button style={styles.backButton} onClick={() => navigate(-1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div style={styles.title}>预约通话</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 通话类型选择 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择通话类型</div>
|
||||||
|
<div style={styles.callTypeSelector}>
|
||||||
|
<button
|
||||||
|
style={styles.callTypeButton(callType === CallType.VOICE)}
|
||||||
|
onClick={() => setCallType(CallType.VOICE)}
|
||||||
|
>
|
||||||
|
📞 语音通话
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={styles.callTypeButton(callType === CallType.VIDEO)}
|
||||||
|
onClick={() => setCallType(CallType.VIDEO)}
|
||||||
|
>
|
||||||
|
📹 视频通话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日期选择 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择日期</div>
|
||||||
|
<div style={styles.dateGrid}>
|
||||||
|
{getDateOptions().map((date) => (
|
||||||
|
<div
|
||||||
|
key={date.value}
|
||||||
|
style={styles.dateOption(selectedDate === date.value)}
|
||||||
|
onClick={() => setSelectedDate(date.value)}
|
||||||
|
>
|
||||||
|
<div style={styles.dateLabel}>{date.label}</div>
|
||||||
|
<div style={styles.dateWeekday}>{date.weekday}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间选择 */}
|
||||||
|
{selectedDate && (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择时间</div>
|
||||||
|
<div style={styles.timeGrid}>
|
||||||
|
{timeSlots.map((time) => {
|
||||||
|
const isAvailable = appointmentService.isTimeSlotAvailable(
|
||||||
|
new Date(`${selectedDate}T${time}:00`),
|
||||||
|
30
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={time}
|
||||||
|
style={styles.timeSlot(selectedTime === time, isAvailable)}
|
||||||
|
onClick={() => isAvailable && setSelectedTime(time)}
|
||||||
|
disabled={!isAvailable}
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 语言选择 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择语言</div>
|
||||||
|
<div style={styles.languageRow}>
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.from}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, from: e.target.value})}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={styles.swapButton}
|
||||||
|
onClick={() => setSelectedLanguages({
|
||||||
|
from: selectedLanguages.to,
|
||||||
|
to: selectedLanguages.from
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.to}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, to: e.target.value})}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 翻译服务选择 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择翻译服务</div>
|
||||||
|
<div style={styles.translationTypeSelector}>
|
||||||
|
{callType === CallType.VOICE && (
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.TEXT)}
|
||||||
|
onClick={() => setTranslationType(TranslationType.TEXT)}
|
||||||
|
>
|
||||||
|
<div>💬 实时文字翻译</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
AI自动翻译,快速准确
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callType === CallType.VIDEO && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.SIGN_LANGUAGE)}
|
||||||
|
onClick={() => setTranslationType(TranslationType.SIGN_LANGUAGE)}
|
||||||
|
>
|
||||||
|
<div>👋 虚拟人手语翻译</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
AI虚拟人实时手语翻译
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.HUMAN_INTERPRETER)}
|
||||||
|
onClick={() => setTranslationType(TranslationType.HUMAN_INTERPRETER)}
|
||||||
|
>
|
||||||
|
<div>👨💼 专业翻译员</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
人工翻译,专业可靠
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 翻译员选择 */}
|
||||||
|
{translationType === TranslationType.HUMAN_INTERPRETER && (
|
||||||
|
<div style={styles.interpreterList}>
|
||||||
|
<div style={styles.sectionTitle}>选择翻译员</div>
|
||||||
|
{availableInterpreters.map((interpreter) => (
|
||||||
|
<div
|
||||||
|
key={interpreter.id}
|
||||||
|
style={styles.interpreterItem(selectedInterpreter?.id === interpreter.id)}
|
||||||
|
onClick={() => setSelectedInterpreter(interpreter)}
|
||||||
|
>
|
||||||
|
<div style={styles.interpreterAvatar}>{interpreter.avatar}</div>
|
||||||
|
<div style={styles.interpreterInfo}>
|
||||||
|
<div style={styles.interpreterName}>{interpreter.name}</div>
|
||||||
|
<div style={styles.interpreterDetails}>
|
||||||
|
{interpreter.languages.join(', ')} | ⭐ {interpreter.rating} | {interpreter.specialties.join(', ')}
|
||||||
|
</div>
|
||||||
|
<div style={styles.interpreterRate}>
|
||||||
|
¥{formatCurrency(interpreter.pricePerMinute)}/分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注信息 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>备注信息(可选)</div>
|
||||||
|
<textarea
|
||||||
|
style={styles.descriptionInput}
|
||||||
|
placeholder="请输入特殊要求或备注信息..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 费用预估 */}
|
||||||
|
{estimatedCost > 0 && (
|
||||||
|
<div style={styles.costSummary}>
|
||||||
|
<div style={styles.costRow}>
|
||||||
|
<span style={styles.costLabel}>预估费用(30分钟)</span>
|
||||||
|
<span style={styles.costValue}>¥{formatCurrency(estimatedCost)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.costRow}>
|
||||||
|
<span style={styles.costLabel}>当前余额</span>
|
||||||
|
<span style={styles.costValue}>
|
||||||
|
¥{userAccount ? formatCurrency(userAccount.balance) : '0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<button
|
||||||
|
style={styles.submitButton}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!selectedDate || !selectedTime || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? '预约中...' : '确认预约'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileAppointment;
|
||||||
698
src/pages/mobile/Call.tsx
Normal file
698
src/pages/mobile/Call.tsx
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
import { FC, useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import { AppointmentService } from '../../services/appointmentService';
|
||||||
|
import {
|
||||||
|
UserAccount,
|
||||||
|
CallType,
|
||||||
|
TranslationType,
|
||||||
|
Interpreter,
|
||||||
|
BillingRule
|
||||||
|
} from '../../types/billing';
|
||||||
|
|
||||||
|
const MobileCall: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const billingService = BillingService.getInstance();
|
||||||
|
const appointmentService = AppointmentService.getInstance();
|
||||||
|
|
||||||
|
// 从URL参数获取通话类型
|
||||||
|
const urlParams = new URLSearchParams(location.search);
|
||||||
|
const initialCallType = urlParams.get('type') === 'video' ? CallType.VIDEO : CallType.VOICE;
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const [callType, setCallType] = useState<CallType>(initialCallType);
|
||||||
|
const [translationType, setTranslationType] = useState<TranslationType>(
|
||||||
|
callType === CallType.VIDEO ? TranslationType.SIGN_LANGUAGE : TranslationType.TEXT
|
||||||
|
);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [callDuration, setCallDuration] = useState(0);
|
||||||
|
const [currentCost, setCurrentCost] = useState(0);
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [selectedInterpreter, setSelectedInterpreter] = useState<Interpreter | null>(null);
|
||||||
|
const [availableInterpreters, setAvailableInterpreters] = useState<Interpreter[]>([]);
|
||||||
|
const [billingRule, setBillingRule] = useState<BillingRule | null>(null);
|
||||||
|
const [showLowBalanceWarning, setShowLowBalanceWarning] = useState(false);
|
||||||
|
const [selectedLanguages, setSelectedLanguages] = useState({
|
||||||
|
from: 'zh-CN',
|
||||||
|
to: 'en-US',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计费相关
|
||||||
|
const [lastBillingMinute, setLastBillingMinute] = useState(0);
|
||||||
|
const billingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 翻译历史
|
||||||
|
const [translationHistory, setTranslationHistory] = useState([
|
||||||
|
{
|
||||||
|
time: '14:23:15',
|
||||||
|
original: '你好,很高兴见到你',
|
||||||
|
translated: 'Hello, nice to meet you',
|
||||||
|
speaker: 'you'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '14:23:18',
|
||||||
|
original: 'Nice to meet you too!',
|
||||||
|
translated: '我也很高兴见到你!',
|
||||||
|
speaker: 'other'
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
|
||||||
|
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
|
||||||
|
{ code: 'ja-JP', name: '日本語', flag: '🇯🇵' },
|
||||||
|
{ code: 'ko-KR', name: '한국어', flag: '🇰🇷' },
|
||||||
|
{ code: 'es-ES', name: 'Español', flag: '🇪🇸' },
|
||||||
|
{ code: 'fr-FR', name: 'Français', flag: '🇫🇷' },
|
||||||
|
{ code: 'de-DE', name: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 获取用户账户信息
|
||||||
|
const account = billingService.getUserAccount();
|
||||||
|
setUserAccount(account);
|
||||||
|
|
||||||
|
// 获取计费规则
|
||||||
|
if (account) {
|
||||||
|
const rule = billingService.getBillingRule(callType, translationType, account.userType);
|
||||||
|
setBillingRule(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用翻译员
|
||||||
|
if (translationType === TranslationType.HUMAN_INTERPRETER) {
|
||||||
|
const interpreters = appointmentService.getAvailableInterpreters(
|
||||||
|
new Date(),
|
||||||
|
[selectedLanguages.from, selectedLanguages.to]
|
||||||
|
);
|
||||||
|
setAvailableInterpreters(interpreters);
|
||||||
|
}
|
||||||
|
}, [callType, translationType, selectedLanguages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (isConnected) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setCallDuration(prev => {
|
||||||
|
const newDuration = prev + 1;
|
||||||
|
const currentMinute = Math.ceil(newDuration / 60);
|
||||||
|
|
||||||
|
// 计算当前费用
|
||||||
|
if (billingRule && userAccount) {
|
||||||
|
const interpreterRate = selectedInterpreter?.pricePerMinute;
|
||||||
|
const cost = billingService.calculateCallCost(
|
||||||
|
callType,
|
||||||
|
translationType,
|
||||||
|
newDuration / 60,
|
||||||
|
interpreterRate
|
||||||
|
);
|
||||||
|
setCurrentCost(cost);
|
||||||
|
|
||||||
|
// 每分钟开始时扣费
|
||||||
|
if (currentMinute > lastBillingMinute) {
|
||||||
|
const minuteCost = billingService.calculateCallCost(
|
||||||
|
callType,
|
||||||
|
translationType,
|
||||||
|
1,
|
||||||
|
interpreterRate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (billingService.deductBalance(minuteCost)) {
|
||||||
|
setLastBillingMinute(currentMinute);
|
||||||
|
setUserAccount(billingService.getUserAccount());
|
||||||
|
} else {
|
||||||
|
// 余额不足,断开通话
|
||||||
|
handleDisconnect();
|
||||||
|
alert('余额不足,通话已断开');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查低余额警告
|
||||||
|
if (billingService.shouldShowLowBalanceWarning(callType, translationType, interpreterRate)) {
|
||||||
|
setShowLowBalanceWarning(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newDuration;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isConnected, billingRule, userAccount, selectedInterpreter, lastBillingMinute]);
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCost = (cents: number) => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (!userAccount || !billingRule) {
|
||||||
|
alert('账户信息或计费规则未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpreterRate = selectedInterpreter?.pricePerMinute;
|
||||||
|
const balanceCheck = billingService.checkBalance(callType, translationType, 1, interpreterRate);
|
||||||
|
|
||||||
|
if (!balanceCheck.sufficient) {
|
||||||
|
alert(`余额不足,需要至少 ¥${formatCost(balanceCheck.requiredAmount)} 才能开始通话`);
|
||||||
|
navigate('/mobile/recharge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(true);
|
||||||
|
setCallDuration(0);
|
||||||
|
setCurrentCost(0);
|
||||||
|
setLastBillingMinute(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
if (billingIntervalRef.current) {
|
||||||
|
clearInterval(billingIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallTypeChange = (newCallType: CallType) => {
|
||||||
|
if (isConnected) {
|
||||||
|
alert('通话进行中无法切换类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallType(newCallType);
|
||||||
|
// 根据通话类型设置默认翻译类型
|
||||||
|
if (newCallType === CallType.VIDEO) {
|
||||||
|
setTranslationType(TranslationType.SIGN_LANGUAGE);
|
||||||
|
} else {
|
||||||
|
setTranslationType(TranslationType.TEXT);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslationTypeChange = (newTranslationType: TranslationType) => {
|
||||||
|
if (isConnected) {
|
||||||
|
alert('通话进行中无法切换翻译类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTranslationType(newTranslationType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInterpreterSelect = (interpreter: Interpreter) => {
|
||||||
|
if (isConnected) {
|
||||||
|
alert('通话进行中无法切换翻译员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedInterpreter(interpreter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapLanguages = () => {
|
||||||
|
setSelectedLanguages({
|
||||||
|
from: selectedLanguages.to,
|
||||||
|
to: selectedLanguages.from,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
callTypeSelector: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
callTypeButton: (active: boolean) => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: active ? '#1890ff' : '#f0f0f0',
|
||||||
|
color: active ? 'white' : '#666',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
|
statusRow: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
statusIndicator: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: isConnected ? '#52c41a' : '#d9d9d9',
|
||||||
|
marginRight: '8px',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: isConnected ? '#52c41a' : '#666',
|
||||||
|
},
|
||||||
|
costInfo: {
|
||||||
|
textAlign: 'right' as const,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
currentCost: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#fa8c16',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
billingInfo: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
billingTitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
translationTypeSelector: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
translationTypeButton: (active: boolean) => ({
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: active ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||||
|
backgroundColor: active ? '#e6f7ff' : 'white',
|
||||||
|
color: active ? '#1890ff' : '#666',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
}),
|
||||||
|
rateInfo: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
interpreterSelector: {
|
||||||
|
marginTop: '12px',
|
||||||
|
},
|
||||||
|
interpreterItem: (selected: boolean) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: selected ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||||
|
backgroundColor: selected ? '#e6f7ff' : 'white',
|
||||||
|
marginBottom: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
|
interpreterInfo: {
|
||||||
|
marginLeft: '12px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
interpreterName: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
interpreterDetails: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
interpreterRate: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#fa8c16',
|
||||||
|
},
|
||||||
|
languageSelector: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
languageRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
languageSelect: {
|
||||||
|
flex: 1,
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
swapButton: {
|
||||||
|
margin: '0 12px',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#f0f8ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
},
|
||||||
|
controlButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '20px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
controlButton: {
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
borderRadius: '40px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '32px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
},
|
||||||
|
connectButton: {
|
||||||
|
backgroundColor: isConnected ? '#ff4d4f' : '#52c41a',
|
||||||
|
},
|
||||||
|
translationContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
translationTitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
translationItem: {
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
translationTime: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
translationText: {
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
originalText: {
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
},
|
||||||
|
translatedText: {
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
speakerTag: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
fontStyle: 'italic' as const,
|
||||||
|
},
|
||||||
|
warningModal: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
warningContent: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
margin: '20px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
warningTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#fa8c16',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
warningButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
warningButton: (primary: boolean) => ({
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: primary ? '#1890ff' : '#f0f0f0',
|
||||||
|
color: primary ? 'white' : '#666',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 通话类型选择 */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.callTypeSelector}>
|
||||||
|
<button
|
||||||
|
style={styles.callTypeButton(callType === CallType.VOICE)}
|
||||||
|
onClick={() => handleCallTypeChange(CallType.VOICE)}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
📞 语音通话
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={styles.callTypeButton(callType === CallType.VIDEO)}
|
||||||
|
onClick={() => handleCallTypeChange(CallType.VIDEO)}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
📹 视频通话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.statusRow}>
|
||||||
|
<div style={styles.statusIndicator}>
|
||||||
|
<div style={styles.statusDot}></div>
|
||||||
|
<span style={styles.statusText}>
|
||||||
|
{isConnected ? '通话中' : '未连接'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.costInfo}>
|
||||||
|
<div style={styles.duration}>{formatDuration(callDuration)}</div>
|
||||||
|
<div style={styles.currentCost}>¥{formatCost(currentCost)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 计费信息 */}
|
||||||
|
<div style={styles.billingInfo}>
|
||||||
|
<div style={styles.billingTitle}>翻译服务选择</div>
|
||||||
|
|
||||||
|
<div style={styles.translationTypeSelector}>
|
||||||
|
{callType === CallType.VOICE && (
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.TEXT)}
|
||||||
|
onClick={() => handleTranslationTypeChange(TranslationType.TEXT)}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
<div>💬 实时文字翻译</div>
|
||||||
|
<div style={styles.rateInfo}>
|
||||||
|
{billingRule && `¥${formatCost(billingRule.pricePerMinute)}/分钟`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callType === CallType.VIDEO && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.SIGN_LANGUAGE)}
|
||||||
|
onClick={() => handleTranslationTypeChange(TranslationType.SIGN_LANGUAGE)}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
<div>👋 虚拟人手语翻译</div>
|
||||||
|
<div style={styles.rateInfo}>
|
||||||
|
{billingRule && `¥${formatCost(billingRule.pricePerMinute)}/分钟`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={styles.translationTypeButton(translationType === TranslationType.HUMAN_INTERPRETER)}
|
||||||
|
onClick={() => handleTranslationTypeChange(TranslationType.HUMAN_INTERPRETER)}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
<div>👨💼 真人翻译员</div>
|
||||||
|
<div style={styles.rateInfo}>
|
||||||
|
{billingRule && `¥${formatCost(billingRule.pricePerMinute)}/分钟 + 翻译员费用`}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 翻译员选择 */}
|
||||||
|
{translationType === TranslationType.HUMAN_INTERPRETER && (
|
||||||
|
<div style={styles.interpreterSelector}>
|
||||||
|
<div style={styles.billingTitle}>选择翻译员</div>
|
||||||
|
{availableInterpreters.map((interpreter) => (
|
||||||
|
<div
|
||||||
|
key={interpreter.id}
|
||||||
|
style={styles.interpreterItem(selectedInterpreter?.id === interpreter.id)}
|
||||||
|
onClick={() => handleInterpreterSelect(interpreter)}
|
||||||
|
>
|
||||||
|
<div>{interpreter.avatar}</div>
|
||||||
|
<div style={styles.interpreterInfo}>
|
||||||
|
<div style={styles.interpreterName}>{interpreter.name}</div>
|
||||||
|
<div style={styles.interpreterDetails}>
|
||||||
|
{interpreter.languages.join(', ')} | ⭐ {interpreter.rating}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.interpreterRate}>
|
||||||
|
¥{formatCost(interpreter.pricePerMinute)}/分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 语言选择器 */}
|
||||||
|
<div style={styles.languageSelector}>
|
||||||
|
<div style={styles.languageRow}>
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.from}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, from: e.target.value})}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button style={styles.swapButton} onClick={swapLanguages} disabled={isConnected}>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.to}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, to: e.target.value})}
|
||||||
|
disabled={isConnected}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制按钮 */}
|
||||||
|
<div style={styles.controlButtons}>
|
||||||
|
<button
|
||||||
|
style={{...styles.controlButton, ...styles.connectButton}}
|
||||||
|
onClick={isConnected ? handleDisconnect : handleConnect}
|
||||||
|
>
|
||||||
|
{isConnected ? '📞' : (callType === CallType.VIDEO ? '📹' : '📱')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 翻译历史 */}
|
||||||
|
<div style={styles.translationContainer}>
|
||||||
|
<h3 style={styles.translationTitle}>
|
||||||
|
{translationType === TranslationType.TEXT ? '实时翻译' :
|
||||||
|
translationType === TranslationType.SIGN_LANGUAGE ? '手语翻译' : '翻译员服务'}
|
||||||
|
</h3>
|
||||||
|
{translationHistory.map((item, index) => (
|
||||||
|
<div key={index} style={styles.translationItem}>
|
||||||
|
<div style={styles.translationTime}>{item.time}</div>
|
||||||
|
<div style={{...styles.translationText, ...styles.originalText}}>
|
||||||
|
{item.original}
|
||||||
|
</div>
|
||||||
|
<div style={{...styles.translationText, ...styles.translatedText}}>
|
||||||
|
{item.translated}
|
||||||
|
</div>
|
||||||
|
<div style={styles.speakerTag}>
|
||||||
|
{item.speaker === 'you' ? '您' : '对方'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 低余额警告 */}
|
||||||
|
{showLowBalanceWarning && (
|
||||||
|
<div style={styles.warningModal}>
|
||||||
|
<div style={styles.warningContent}>
|
||||||
|
<div style={styles.warningTitle}>⚠️ 余额不足警告</div>
|
||||||
|
<div style={styles.warningText}>
|
||||||
|
您的账户余额即将不足,可能无法维持通话超过5分钟。
|
||||||
|
建议立即充值以避免通话中断。
|
||||||
|
</div>
|
||||||
|
<div style={styles.warningButtons}>
|
||||||
|
<button
|
||||||
|
style={styles.warningButton(false)}
|
||||||
|
onClick={() => setShowLowBalanceWarning(false)}
|
||||||
|
>
|
||||||
|
继续通话
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={styles.warningButton(true)}
|
||||||
|
onClick={() => {
|
||||||
|
setShowLowBalanceWarning(false);
|
||||||
|
navigate('/mobile/recharge');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即充值
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileCall;
|
||||||
407
src/pages/mobile/Documents.tsx
Normal file
407
src/pages/mobile/Documents.tsx
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
status: 'uploading' | 'processing' | 'completed' | 'failed';
|
||||||
|
progress: number;
|
||||||
|
originalLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
uploadTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileDocuments: FC = () => {
|
||||||
|
const [documents, setDocuments] = useState<Document[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '商业合同.pdf',
|
||||||
|
size: '2.3 MB',
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
originalLanguage: 'zh-CN',
|
||||||
|
targetLanguage: 'en-US',
|
||||||
|
uploadTime: '2024-01-15 14:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '技术文档.docx',
|
||||||
|
size: '1.8 MB',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 65,
|
||||||
|
originalLanguage: 'en-US',
|
||||||
|
targetLanguage: 'zh-CN',
|
||||||
|
uploadTime: '2024-01-15 15:20',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedLanguages, setSelectedLanguages] = useState({
|
||||||
|
from: 'zh-CN',
|
||||||
|
to: 'en-US',
|
||||||
|
});
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
|
||||||
|
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
|
||||||
|
{ code: 'ja-JP', name: '日本語', flag: '🇯🇵' },
|
||||||
|
{ code: 'ko-KR', name: '한국어', flag: '🇰🇷' },
|
||||||
|
{ code: 'es-ES', name: 'Español', flag: '🇪🇸' },
|
||||||
|
{ code: 'fr-FR', name: 'Français', flag: '🇫🇷' },
|
||||||
|
{ code: 'de-DE', name: 'Deutsch', flag: '🇩🇪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
const newDocument: Document = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: file.name,
|
||||||
|
size: `${(file.size / 1024 / 1024).toFixed(1)} MB`,
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0,
|
||||||
|
originalLanguage: selectedLanguages.from,
|
||||||
|
targetLanguage: selectedLanguages.to,
|
||||||
|
uploadTime: new Date().toLocaleString('zh-CN'),
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocuments(prev => [newDocument, ...prev]);
|
||||||
|
|
||||||
|
// 模拟上传进度
|
||||||
|
simulateUpload(newDocument.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulateUpload = (docId: string) => {
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += Math.random() * 15;
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 100;
|
||||||
|
setDocuments(prev => prev.map(doc =>
|
||||||
|
doc.id === docId
|
||||||
|
? { ...doc, status: 'processing', progress: 0 }
|
||||||
|
: doc
|
||||||
|
));
|
||||||
|
clearInterval(interval);
|
||||||
|
simulateProcessing(docId);
|
||||||
|
} else {
|
||||||
|
setDocuments(prev => prev.map(doc =>
|
||||||
|
doc.id === docId
|
||||||
|
? { ...doc, progress: Math.floor(progress) }
|
||||||
|
: doc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulateProcessing = (docId: string) => {
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += Math.random() * 10;
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 100;
|
||||||
|
setDocuments(prev => prev.map(doc =>
|
||||||
|
doc.id === docId
|
||||||
|
? { ...doc, status: 'completed', progress: 100 }
|
||||||
|
: doc
|
||||||
|
));
|
||||||
|
clearInterval(interval);
|
||||||
|
} else {
|
||||||
|
setDocuments(prev => prev.map(doc =>
|
||||||
|
doc.id === docId
|
||||||
|
? { ...doc, progress: Math.floor(progress) }
|
||||||
|
: doc
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: Document['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'uploading': return '上传中';
|
||||||
|
case 'processing': return '翻译中';
|
||||||
|
case 'completed': return '已完成';
|
||||||
|
case 'failed': return '失败';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Document['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'uploading': return '#1890ff';
|
||||||
|
case 'processing': return '#fa8c16';
|
||||||
|
case 'completed': return '#52c41a';
|
||||||
|
case 'failed': return '#ff4d4f';
|
||||||
|
default: return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapLanguages = () => {
|
||||||
|
setSelectedLanguages({
|
||||||
|
from: selectedLanguages.to,
|
||||||
|
to: selectedLanguages.from,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100%',
|
||||||
|
},
|
||||||
|
uploadSection: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
uploadTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
languageSelector: {
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
languageRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
languageSelect: {
|
||||||
|
flex: 1,
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
swapButton: {
|
||||||
|
margin: '0 12px',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#f0f8ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
},
|
||||||
|
uploadArea: {
|
||||||
|
border: '2px dashed #d9d9d9',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.3s ease',
|
||||||
|
},
|
||||||
|
uploadIcon: {
|
||||||
|
fontSize: '48px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
uploadText: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
uploadSubtext: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
hiddenInput: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
documentsSection: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
documentItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
documentIcon: {
|
||||||
|
fontSize: '24px',
|
||||||
|
marginRight: '12px',
|
||||||
|
},
|
||||||
|
documentInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
documentName: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
documentDetails: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
documentLanguages: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
documentStatus: {
|
||||||
|
textAlign: 'right' as const,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
width: '60px',
|
||||||
|
height: '4px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: '4px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<div style={styles.uploadSection}>
|
||||||
|
<h2 style={styles.uploadTitle}>📄 文档翻译</h2>
|
||||||
|
|
||||||
|
{/* 语言选择 */}
|
||||||
|
<div style={styles.languageSelector}>
|
||||||
|
<div style={styles.languageRow}>
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.from}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, from: e.target.value})}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button style={styles.swapButton} onClick={swapLanguages}>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
style={styles.languageSelect}
|
||||||
|
value={selectedLanguages.to}
|
||||||
|
onChange={(e) => setSelectedLanguages({...selectedLanguages, to: e.target.value})}
|
||||||
|
>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<label style={styles.uploadArea}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
style={styles.hiddenInput}
|
||||||
|
accept=".pdf,.doc,.docx,.txt"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<div style={styles.uploadIcon}>📁</div>
|
||||||
|
<div style={styles.uploadText}>点击上传文档</div>
|
||||||
|
<div style={styles.uploadSubtext}>
|
||||||
|
支持 PDF、DOC、DOCX、TXT 格式
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文档列表 */}
|
||||||
|
<div style={styles.documentsSection}>
|
||||||
|
<h3 style={styles.sectionTitle}>我的文档</h3>
|
||||||
|
|
||||||
|
{documents.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||||
|
暂无文档,请上传您的第一个文档
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
documents.map((doc) => (
|
||||||
|
<div key={doc.id} style={styles.documentItem}>
|
||||||
|
<div style={styles.documentIcon}>
|
||||||
|
{doc.name.endsWith('.pdf') ? '📄' : '📝'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.documentInfo}>
|
||||||
|
<div style={styles.documentName}>{doc.name}</div>
|
||||||
|
<div style={styles.documentDetails}>
|
||||||
|
{doc.size} • {doc.uploadTime}
|
||||||
|
</div>
|
||||||
|
<div style={styles.documentLanguages}>
|
||||||
|
{languages.find(l => l.code === doc.originalLanguage)?.flag} → {' '}
|
||||||
|
{languages.find(l => l.code === doc.targetLanguage)?.flag}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.documentStatus}>
|
||||||
|
<div style={{
|
||||||
|
...styles.statusText,
|
||||||
|
color: getStatusColor(doc.status)
|
||||||
|
}}>
|
||||||
|
{getStatusText(doc.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doc.status !== 'completed' && (
|
||||||
|
<div style={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.progressFill,
|
||||||
|
width: `${doc.progress}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{doc.status === 'completed' && (
|
||||||
|
<button style={styles.actionButton}>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileDocuments;
|
||||||
388
src/pages/mobile/Home.tsx
Normal file
388
src/pages/mobile/Home.tsx
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import { AppointmentService } from '../../services/appointmentService';
|
||||||
|
import { UserType, UserAccount, Appointment } from '../../types/billing';
|
||||||
|
|
||||||
|
const MobileHome: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [upcomingAppointments, setUpcomingAppointments] = useState<Appointment[]>([]);
|
||||||
|
const billingService = BillingService.getInstance();
|
||||||
|
const appointmentService = AppointmentService.getInstance();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 模拟用户账户数据
|
||||||
|
const mockAccount: UserAccount = {
|
||||||
|
id: 'user_1',
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
balance: 5000, // 50元
|
||||||
|
};
|
||||||
|
|
||||||
|
billingService.setUserAccount(mockAccount);
|
||||||
|
setUserAccount(mockAccount);
|
||||||
|
|
||||||
|
// 获取即将到来的预约
|
||||||
|
const appointments = appointmentService.getUpcomingAppointments('user_1', 3);
|
||||||
|
setUpcomingAppointments(appointments);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '语音通话',
|
||||||
|
description: '发起实时语音翻译通话',
|
||||||
|
icon: '📞',
|
||||||
|
color: '#52c41a',
|
||||||
|
action: () => navigate('/mobile/call?type=voice'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '视频通话',
|
||||||
|
description: '发起视频通话和手语翻译',
|
||||||
|
icon: '📹',
|
||||||
|
color: '#1890ff',
|
||||||
|
action: () => navigate('/mobile/call?type=video'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '预约通话',
|
||||||
|
description: '预约专业翻译员服务',
|
||||||
|
icon: '📅',
|
||||||
|
color: '#722ed1',
|
||||||
|
action: () => navigate('/mobile/appointment'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '充值',
|
||||||
|
description: '账户余额充值',
|
||||||
|
icon: '💰',
|
||||||
|
color: '#fa8c16',
|
||||||
|
action: () => navigate('/mobile/recharge'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentActivities = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'call',
|
||||||
|
title: '与 John Smith 的通话',
|
||||||
|
time: '2小时前',
|
||||||
|
status: '已完成',
|
||||||
|
cost: '¥15.50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'appointment',
|
||||||
|
title: '商务会议翻译',
|
||||||
|
time: '明天 14:00',
|
||||||
|
status: '已预约',
|
||||||
|
cost: '¥180.00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'call',
|
||||||
|
title: '医疗咨询翻译',
|
||||||
|
time: '2天前',
|
||||||
|
status: '已完成',
|
||||||
|
cost: '¥32.00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatBalance = (balance: number) => {
|
||||||
|
return (balance / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActivityIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'call': return '📞';
|
||||||
|
case 'appointment': return '📅';
|
||||||
|
case 'recharge': return '💰';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case '已完成': return '#52c41a';
|
||||||
|
case '已预约': return '#1890ff';
|
||||||
|
case '进行中': return '#fa8c16';
|
||||||
|
case '已取消': return '#ff4d4f';
|
||||||
|
default: return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100%',
|
||||||
|
},
|
||||||
|
balanceCard: {
|
||||||
|
backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||||
|
},
|
||||||
|
balanceTitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: 0.9,
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
balanceAmount: {
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: '700' as const,
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
balanceSubtitle: {
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
userTypeTag: {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
},
|
||||||
|
welcomeCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
welcomeTitle: {
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
welcomeSubtitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '0',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
marginTop: '24px',
|
||||||
|
},
|
||||||
|
quickActionsGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
},
|
||||||
|
actionCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||||
|
position: 'relative' as const,
|
||||||
|
},
|
||||||
|
actionIcon: {
|
||||||
|
fontSize: '32px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
actionTitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
actionDescription: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
},
|
||||||
|
appointmentPreview: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
appointmentTitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
appointmentItem: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
},
|
||||||
|
appointmentInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
appointmentName: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '2px',
|
||||||
|
},
|
||||||
|
appointmentTime: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
appointmentStatus: {
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#e6f7ff',
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
activityList: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
activityItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
},
|
||||||
|
activityIcon: {
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
backgroundColor: '#f0f8ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: '12px',
|
||||||
|
fontSize: '18px',
|
||||||
|
},
|
||||||
|
activityContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activityTitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
activityTime: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
activityRight: {
|
||||||
|
textAlign: 'right' as const,
|
||||||
|
},
|
||||||
|
activityStatus: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
activityCost: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionPress = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 余额卡片 */}
|
||||||
|
{userAccount && (
|
||||||
|
<div style={styles.balanceCard}>
|
||||||
|
<div style={styles.userTypeTag}>
|
||||||
|
{userAccount.userType === UserType.INDIVIDUAL ? '个人用户' : '企业用户'}
|
||||||
|
</div>
|
||||||
|
<div style={styles.balanceTitle}>账户余额</div>
|
||||||
|
<div style={styles.balanceAmount}>¥{formatBalance(userAccount.balance)}</div>
|
||||||
|
<div style={styles.balanceSubtitle}>可用于通话和翻译服务</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<h2 style={styles.sectionTitle}>快捷操作</h2>
|
||||||
|
<div style={styles.quickActionsGrid}>
|
||||||
|
{quickActions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
style={styles.actionCard}
|
||||||
|
onClick={() => handleActionPress(action.action)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.actionIcon}>{action.icon}</span>
|
||||||
|
<div style={styles.actionTitle}>{action.title}</div>
|
||||||
|
<div style={styles.actionDescription}>{action.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 即将到来的预约 */}
|
||||||
|
{upcomingAppointments.length > 0 && (
|
||||||
|
<div style={styles.appointmentPreview}>
|
||||||
|
<div style={styles.appointmentTitle}>即将到来的预约</div>
|
||||||
|
{upcomingAppointments.map((appointment) => (
|
||||||
|
<div key={appointment.id} style={styles.appointmentItem}>
|
||||||
|
<div style={styles.appointmentInfo}>
|
||||||
|
<div style={styles.appointmentName}>{appointment.title}</div>
|
||||||
|
<div style={styles.appointmentTime}>
|
||||||
|
{appointment.scheduledTime.toLocaleDateString()} {appointment.scheduledTime.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.appointmentStatus}>{appointment.status}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 最近活动 */}
|
||||||
|
<h2 style={styles.sectionTitle}>最近活动</h2>
|
||||||
|
<div style={styles.activityList}>
|
||||||
|
{recentActivities.map((activity) => (
|
||||||
|
<div key={activity.id} style={styles.activityItem}>
|
||||||
|
<div style={styles.activityIcon}>
|
||||||
|
{getActivityIcon(activity.type)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.activityContent}>
|
||||||
|
<div style={styles.activityTitle}>{activity.title}</div>
|
||||||
|
<div style={styles.activityTime}>{activity.time}</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.activityRight}>
|
||||||
|
<div style={{...styles.activityStatus, color: getStatusColor(activity.status)}}>
|
||||||
|
{activity.status}
|
||||||
|
</div>
|
||||||
|
<div style={styles.activityCost}>{activity.cost}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileHome;
|
||||||
497
src/pages/mobile/Recharge.tsx
Normal file
497
src/pages/mobile/Recharge.tsx
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import { UserAccount, UserType, RechargeRecord } from '../../types/billing';
|
||||||
|
|
||||||
|
const MobileRecharge: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const billingService = BillingService.getInstance();
|
||||||
|
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState<number>(0);
|
||||||
|
const [customAmount, setCustomAmount] = useState<string>('');
|
||||||
|
const [selectedPayment, setSelectedPayment] = useState<'wechat' | 'alipay' | 'card'>('wechat');
|
||||||
|
const [rechargeHistory, setRechargeHistory] = useState<RechargeRecord[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
// 预设充值金额(分)
|
||||||
|
const presetAmounts = [
|
||||||
|
{ value: 5000, label: '¥50', bonus: 0, popular: false },
|
||||||
|
{ value: 10000, label: '¥100', bonus: 500, popular: true },
|
||||||
|
{ value: 20000, label: '¥200', bonus: 1500, popular: false },
|
||||||
|
{ value: 50000, label: '¥500', bonus: 5000, popular: false },
|
||||||
|
{ value: 100000, label: '¥1000', bonus: 15000, popular: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentMethods = [
|
||||||
|
{ id: 'wechat', name: '微信支付', icon: '💚', color: '#07c160' },
|
||||||
|
{ id: 'alipay', name: '支付宝', icon: '💙', color: '#1677ff' },
|
||||||
|
{ id: 'card', name: '银行卡', icon: '💳', color: '#722ed1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const account = billingService.getUserAccount();
|
||||||
|
setUserAccount(account);
|
||||||
|
|
||||||
|
// 模拟充值历史
|
||||||
|
const mockHistory: RechargeRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: account?.id || '1',
|
||||||
|
amount: 10000,
|
||||||
|
bonus: 500,
|
||||||
|
paymentMethod: 'wechat',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date(Date.now() - 86400000), // 1天前
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: account?.id || '1',
|
||||||
|
amount: 5000,
|
||||||
|
bonus: 0,
|
||||||
|
paymentMethod: 'alipay',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date(Date.now() - 7 * 86400000), // 7天前
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setRechargeHistory(mockHistory);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatCurrency = (cents: number) => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAmountSelect = (amount: number) => {
|
||||||
|
setSelectedAmount(amount);
|
||||||
|
setCustomAmount('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomAmountChange = (value: string) => {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (!isNaN(numValue) && numValue > 0) {
|
||||||
|
setSelectedAmount(Math.round(numValue * 100));
|
||||||
|
setCustomAmount(value);
|
||||||
|
} else {
|
||||||
|
setSelectedAmount(0);
|
||||||
|
setCustomAmount(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBonus = (amount: number) => {
|
||||||
|
const preset = presetAmounts.find(p => p.value === amount);
|
||||||
|
return preset?.bonus || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTotalAmount = () => {
|
||||||
|
return selectedAmount + getBonus(selectedAmount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecharge = async () => {
|
||||||
|
if (selectedAmount < 100) { // 最低1元
|
||||||
|
alert('充值金额不能少于1元');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userAccount) {
|
||||||
|
alert('用户账户信息未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 模拟支付处理
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// 执行充值
|
||||||
|
const success = billingService.rechargeAccount(selectedAmount);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 更新用户账户
|
||||||
|
setUserAccount(billingService.getUserAccount());
|
||||||
|
|
||||||
|
// 添加充值记录
|
||||||
|
const newRecord: RechargeRecord = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
userId: userAccount.id,
|
||||||
|
amount: selectedAmount,
|
||||||
|
bonus: getBonus(selectedAmount),
|
||||||
|
paymentMethod: selectedPayment,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
setRechargeHistory(prev => [newRecord, ...prev]);
|
||||||
|
|
||||||
|
alert(`充值成功!到账金额:¥${formatCurrency(getTotalAmount())}`);
|
||||||
|
setSelectedAmount(0);
|
||||||
|
setCustomAmount('');
|
||||||
|
} else {
|
||||||
|
alert('充值失败,请重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('充值失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#52c41a';
|
||||||
|
case 'pending':
|
||||||
|
return '#fa8c16';
|
||||||
|
case 'failed':
|
||||||
|
return '#ff4d4f';
|
||||||
|
default:
|
||||||
|
return '#d9d9d9';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '成功';
|
||||||
|
case 'pending':
|
||||||
|
return '处理中';
|
||||||
|
case 'failed':
|
||||||
|
return '失败';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100vh',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '12px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
balanceCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
balanceLabel: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
balanceAmount: {
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: '700' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
userType: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
backgroundColor: '#f0f8ff',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
amountGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
amountCard: (selected: boolean, popular: boolean) => ({
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: selected ? '2px solid #1890ff' : '2px solid transparent',
|
||||||
|
backgroundColor: selected ? '#e6f7ff' : '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
position: 'relative' as const,
|
||||||
|
...(popular && {
|
||||||
|
borderColor: '#fa8c16',
|
||||||
|
backgroundColor: '#fff7e6',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
popularBadge: {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '-8px',
|
||||||
|
right: '-8px',
|
||||||
|
backgroundColor: '#fa8c16',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
},
|
||||||
|
amountValue: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
bonusText: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#52c41a',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
},
|
||||||
|
customAmountInput: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
fontSize: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
paymentMethod: (selected: boolean, color: string) => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: selected ? `2px solid ${color}` : '2px solid #f0f0f0',
|
||||||
|
backgroundColor: selected ? `${color}10` : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
}),
|
||||||
|
paymentIcon: {
|
||||||
|
fontSize: '24px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
paymentName: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
summaryRow: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
borderTop: '1px solid #e8e8e8',
|
||||||
|
paddingTop: '8px',
|
||||||
|
marginTop: '8px',
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
rechargeButton: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: selectedAmount > 0 && !isProcessing ? '#1890ff' : '#d9d9d9',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
cursor: selectedAmount > 0 && !isProcessing ? 'pointer' : 'not-allowed',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
historyItem: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
},
|
||||||
|
historyLeft: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
historyAmount: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
historyDate: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
historyStatus: (status: string) => ({
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
color: getStatusColor(status),
|
||||||
|
backgroundColor: `${getStatusColor(status)}15`,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<button style={styles.backButton} onClick={() => navigate(-1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div style={styles.title}>账户充值</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 余额显示 */}
|
||||||
|
<div style={styles.balanceCard}>
|
||||||
|
<div style={styles.balanceLabel}>当前余额</div>
|
||||||
|
<div style={styles.balanceAmount}>
|
||||||
|
¥{userAccount ? formatCurrency(userAccount.balance) : '0.00'}
|
||||||
|
</div>
|
||||||
|
<div style={styles.userType}>
|
||||||
|
{userAccount?.userType === UserType.ENTERPRISE ? '企业用户' : '个人用户'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 充值金额选择 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择充值金额</div>
|
||||||
|
<div style={styles.amountGrid}>
|
||||||
|
{presetAmounts.map((amount) => (
|
||||||
|
<div
|
||||||
|
key={amount.value}
|
||||||
|
style={styles.amountCard(selectedAmount === amount.value, amount.popular)}
|
||||||
|
onClick={() => handleAmountSelect(amount.value)}
|
||||||
|
>
|
||||||
|
{amount.popular && <div style={styles.popularBadge}>推荐</div>}
|
||||||
|
<div style={styles.amountValue}>{amount.label}</div>
|
||||||
|
{amount.bonus > 0 && (
|
||||||
|
<div style={styles.bonusText}>
|
||||||
|
送¥{formatCurrency(amount.bonus)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="自定义金额(元)"
|
||||||
|
style={styles.customAmountInput}
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 支付方式 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>选择支付方式</div>
|
||||||
|
<div style={styles.paymentMethods}>
|
||||||
|
{paymentMethods.map((method) => (
|
||||||
|
<div
|
||||||
|
key={method.id}
|
||||||
|
style={styles.paymentMethod(
|
||||||
|
selectedPayment === method.id,
|
||||||
|
method.color
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedPayment(method.id as any)}
|
||||||
|
>
|
||||||
|
<div style={styles.paymentIcon}>{method.icon}</div>
|
||||||
|
<div style={styles.paymentName}>{method.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 费用明细 */}
|
||||||
|
{selectedAmount > 0 && (
|
||||||
|
<div style={styles.summaryCard}>
|
||||||
|
<div style={styles.summaryRow}>
|
||||||
|
<span style={styles.summaryLabel}>充值金额</span>
|
||||||
|
<span style={styles.summaryValue}>¥{formatCurrency(selectedAmount)}</span>
|
||||||
|
</div>
|
||||||
|
{getBonus(selectedAmount) > 0 && (
|
||||||
|
<div style={styles.summaryRow}>
|
||||||
|
<span style={styles.summaryLabel}>赠送金额</span>
|
||||||
|
<span style={{...styles.summaryValue, color: '#52c41a'}}>
|
||||||
|
+¥{formatCurrency(getBonus(selectedAmount))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{...styles.summaryRow, ...styles.totalRow}}>
|
||||||
|
<span style={styles.summaryLabel}>到账金额</span>
|
||||||
|
<span style={styles.totalValue}>¥{formatCurrency(getTotalAmount())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 充值按钮 */}
|
||||||
|
<button
|
||||||
|
style={styles.rechargeButton}
|
||||||
|
onClick={handleRecharge}
|
||||||
|
disabled={selectedAmount === 0 || isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? '处理中...' : `确认充值 ¥${formatCurrency(selectedAmount)}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 充值历史 */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>充值记录</div>
|
||||||
|
{rechargeHistory.map((record) => (
|
||||||
|
<div key={record.id} style={styles.historyItem}>
|
||||||
|
<div style={styles.historyLeft}>
|
||||||
|
<div style={styles.historyAmount}>
|
||||||
|
+¥{formatCurrency(record.amount + record.bonus)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.historyDate}>
|
||||||
|
{record.createdAt.toLocaleDateString()} {record.createdAt.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.historyStatus(record.status)}>
|
||||||
|
{getStatusText(record.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileRecharge;
|
||||||
357
src/pages/mobile/Settings.tsx
Normal file
357
src/pages/mobile/Settings.tsx
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { BillingService } from '../../services/billingService';
|
||||||
|
import { UserAccount, UserType } from '../../types/billing';
|
||||||
|
|
||||||
|
const MobileSettings: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const billingService = BillingService.getInstance();
|
||||||
|
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState({
|
||||||
|
callReminder: true,
|
||||||
|
balanceAlert: true,
|
||||||
|
promotions: false,
|
||||||
|
});
|
||||||
|
const [languageSettings, setLanguageSettings] = useState({
|
||||||
|
interface: 'zh-CN',
|
||||||
|
defaultSource: 'zh-CN',
|
||||||
|
defaultTarget: 'en-US',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const account = billingService.getUserAccount();
|
||||||
|
setUserAccount(account);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatCurrency = (cents: number) => {
|
||||||
|
return `¥${(cents / 100).toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTypeText = (userType: UserType) => {
|
||||||
|
return userType === UserType.INDIVIDUAL ? '个人用户' : '企业用户';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
const confirmed = confirm('确定要退出登录吗?');
|
||||||
|
if (confirmed) {
|
||||||
|
// 这里应该清除用户登录状态
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsOptions = [
|
||||||
|
{
|
||||||
|
category: '账户信息',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '个人资料',
|
||||||
|
value: userAccount?.id || '未设置',
|
||||||
|
icon: '👤',
|
||||||
|
onClick: () => navigate('/mobile/profile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '账户类型',
|
||||||
|
value: userAccount ? getUserTypeText(userAccount.userType) : '未知',
|
||||||
|
icon: '🏷️',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '账户余额',
|
||||||
|
value: userAccount ? formatCurrency(userAccount.balance) : '¥0.00',
|
||||||
|
icon: '💰',
|
||||||
|
onClick: () => navigate('/mobile/recharge'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '通话设置',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '默认通话类型',
|
||||||
|
value: '语音通话',
|
||||||
|
icon: '📞',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '音质设置',
|
||||||
|
value: '高清',
|
||||||
|
icon: '🎵',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '自动录音',
|
||||||
|
value: '关闭',
|
||||||
|
icon: '🎙️',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '翻译设置',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '默认源语言',
|
||||||
|
value: '中文',
|
||||||
|
icon: '🌐',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '默认目标语言',
|
||||||
|
value: 'English',
|
||||||
|
icon: '🌍',
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '翻译历史',
|
||||||
|
value: '查看记录',
|
||||||
|
icon: '📝',
|
||||||
|
onClick: () => navigate('/mobile/translation-history'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '通知设置',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '通话提醒',
|
||||||
|
value: notificationSettings.callReminder ? '开启' : '关闭',
|
||||||
|
icon: '🔔',
|
||||||
|
onClick: () => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
callReminder: !prev.callReminder
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '余额提醒',
|
||||||
|
value: notificationSettings.balanceAlert ? '开启' : '关闭',
|
||||||
|
icon: '⚠️',
|
||||||
|
onClick: () => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
balanceAlert: !prev.balanceAlert
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '推广消息',
|
||||||
|
value: notificationSettings.promotions ? '开启' : '关闭',
|
||||||
|
icon: '📢',
|
||||||
|
onClick: () => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
promotions: !prev.promotions
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '帮助中心',
|
||||||
|
value: '',
|
||||||
|
icon: '❓',
|
||||||
|
onClick: () => navigate('/mobile/help'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '意见反馈',
|
||||||
|
value: '',
|
||||||
|
icon: '💬',
|
||||||
|
onClick: () => navigate('/mobile/feedback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关于我们',
|
||||||
|
value: 'v1.0.0',
|
||||||
|
icon: 'ℹ️',
|
||||||
|
onClick: () => navigate('/mobile/about'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '退出登录',
|
||||||
|
value: '',
|
||||||
|
icon: '🚪',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
minHeight: '100vh',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '12px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
userCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
margin: '16px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '24px',
|
||||||
|
color: 'white',
|
||||||
|
marginRight: '16px',
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '4px',
|
||||||
|
},
|
||||||
|
userType: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '8px',
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#1890ff',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
margin: '16px',
|
||||||
|
marginTop: '8px',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingLeft: '4px',
|
||||||
|
},
|
||||||
|
settingsGroup: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
padding: '16px 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid #f8f8f8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
},
|
||||||
|
settingItemLast: {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
settingIcon: {
|
||||||
|
fontSize: '20px',
|
||||||
|
marginRight: '12px',
|
||||||
|
width: '24px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
settingContent: {
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
settingLabel: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
settingValue: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#ccc',
|
||||||
|
marginLeft: '8px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={styles.header}>
|
||||||
|
<button style={styles.backButton} onClick={() => navigate(-1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 style={styles.title}>设置</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户信息卡片 */}
|
||||||
|
<div style={styles.userCard}>
|
||||||
|
<div style={styles.avatar}>
|
||||||
|
👤
|
||||||
|
</div>
|
||||||
|
<div style={styles.userInfo}>
|
||||||
|
<div style={styles.userName}>
|
||||||
|
{userAccount?.id || '用户'}
|
||||||
|
</div>
|
||||||
|
<div style={styles.userType}>
|
||||||
|
{userAccount ? getUserTypeText(userAccount.userType) : '未知类型'}
|
||||||
|
</div>
|
||||||
|
<div style={styles.balance}>
|
||||||
|
余额: {userAccount ? formatCurrency(userAccount.balance) : '¥0.00'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设置选项 */}
|
||||||
|
{settingsOptions.map((section, sectionIndex) => (
|
||||||
|
<div key={sectionIndex} style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>{section.category}</div>
|
||||||
|
<div style={styles.settingsGroup}>
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<div
|
||||||
|
key={itemIndex}
|
||||||
|
style={{
|
||||||
|
...styles.settingItem,
|
||||||
|
...(itemIndex === section.items.length - 1 ? styles.settingItemLast : {}),
|
||||||
|
}}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
<div style={styles.settingIcon}>{item.icon}</div>
|
||||||
|
<div style={styles.settingContent}>
|
||||||
|
<div style={styles.settingLabel}>{item.label}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{item.value && (
|
||||||
|
<div style={styles.settingValue}>{item.value}</div>
|
||||||
|
)}
|
||||||
|
<div style={styles.arrow}>›</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileSettings;
|
||||||
@ -3,12 +3,20 @@ import { AppLayout } from '@/components/Layout';
|
|||||||
import { Dashboard, UserList, CallList } from '@/pages';
|
import { Dashboard, UserList, CallList } from '@/pages';
|
||||||
import { useAuth } from '@/store';
|
import { useAuth } from '@/store';
|
||||||
|
|
||||||
// 导入移动端页面 - 使用Web版本
|
// 导入移动端组件
|
||||||
import HomeScreen from '@/screens/HomeScreen.web';
|
import MobileLayout from '@/components/MobileLayout';
|
||||||
import CallScreen from '@/screens/CallScreen.web';
|
import MobileHome from '@/pages/mobile/Home';
|
||||||
import DocumentScreen from '@/screens/DocumentScreen.web';
|
import MobileCall from '@/pages/mobile/Call';
|
||||||
import SettingsScreen from '@/screens/SettingsScreen.web';
|
import MobileDocuments from '@/pages/mobile/Documents';
|
||||||
import MobileNavigation from '@/components/MobileNavigation.web';
|
import MobileSettings from '@/pages/mobile/Settings';
|
||||||
|
import MobileRecharge from '@/pages/mobile/Recharge';
|
||||||
|
import MobileAppointment from '@/pages/mobile/Appointment';
|
||||||
|
|
||||||
|
// 导入设备重定向组件
|
||||||
|
import DeviceRedirect from '@/components/DeviceRedirect';
|
||||||
|
|
||||||
|
// 导入视频通话测试组件
|
||||||
|
import VideoCallTest from '@/components/VideoCallTest';
|
||||||
|
|
||||||
// 导入视频通话页面
|
// 导入视频通话页面
|
||||||
import { VideoCallPage } from '@/pages/VideoCall/VideoCallPage';
|
import { VideoCallPage } from '@/pages/VideoCall/VideoCallPage';
|
||||||
@ -24,7 +32,7 @@ const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
return !isAuthenticated ? <>{children}</> : <Navigate to="/dashboard" replace />;
|
return !isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 登录页面(临时占位符)
|
// 登录页面(临时占位符)
|
||||||
@ -57,25 +65,6 @@ const NotFoundPage = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 移动端布局组件
|
|
||||||
const MobileLayout = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100vh',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
height: 'calc(100vh - 80px)',
|
|
||||||
overflow: 'auto'
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<MobileNavigation />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AppRoutes = () => {
|
const AppRoutes = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -89,6 +78,22 @@ const AppRoutes = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 视频通话测试路由 - 独立访问 */}
|
||||||
|
<Route
|
||||||
|
path="/video-test"
|
||||||
|
element={<VideoCallTest />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 根路径 - 智能重定向 */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<DeviceRedirect />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 移动端路由 */}
|
{/* 移动端路由 */}
|
||||||
<Route
|
<Route
|
||||||
path="/mobile/*"
|
path="/mobile/*"
|
||||||
@ -97,10 +102,13 @@ const AppRoutes = () => {
|
|||||||
<MobileLayout>
|
<MobileLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/mobile/home" replace />} />
|
<Route path="/" element={<Navigate to="/mobile/home" replace />} />
|
||||||
<Route path="/home" element={<HomeScreen />} />
|
<Route path="/home" element={<MobileHome />} />
|
||||||
<Route path="/call" element={<CallScreen />} />
|
<Route path="/call" element={<MobileCall />} />
|
||||||
<Route path="/documents" element={<DocumentScreen />} />
|
<Route path="/documents" element={<MobileDocuments />} />
|
||||||
<Route path="/settings" element={<SettingsScreen />} />
|
<Route path="/settings" element={<MobileSettings />} />
|
||||||
|
<Route path="/recharge" element={<MobileRecharge />} />
|
||||||
|
<Route path="/appointment" element={<MobileAppointment />} />
|
||||||
|
<Route path="/video-test" element={<VideoCallTest />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MobileLayout>
|
</MobileLayout>
|
||||||
@ -115,9 +123,6 @@ const AppRoutes = () => {
|
|||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* 默认重定向到仪表板 */}
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
|
|
||||||
{/* 仪表板 */}
|
{/* 仪表板 */}
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
|
||||||
@ -127,6 +132,9 @@ const AppRoutes = () => {
|
|||||||
{/* 通话记录 */}
|
{/* 通话记录 */}
|
||||||
<Route path="/calls" element={<CallList />} />
|
<Route path="/calls" element={<CallList />} />
|
||||||
|
|
||||||
|
{/* 视频通话测试 */}
|
||||||
|
<Route path="/video-test" element={<VideoCallTest />} />
|
||||||
|
|
||||||
{/* 文档管理 - 待实现 */}
|
{/* 文档管理 - 待实现 */}
|
||||||
<Route
|
<Route
|
||||||
path="/documents"
|
path="/documents"
|
||||||
|
|||||||
@ -9,20 +9,47 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { mockLanguages } from '@/utils/mockData';
|
import { BillingService } from '../services/billingService';
|
||||||
import { Language, CallSession } from '@/types';
|
import { CallType, TranslationType, UserAccount, UserType } from '../types/billing';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
// 模拟语言数据
|
||||||
|
const mockLanguages = [
|
||||||
|
{ code: 'zh', name: '中文', nativeName: '中文' },
|
||||||
|
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||||
|
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||||
|
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
nativeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallSession {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
mode: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
status: 'active' | 'completed' | 'failed';
|
||||||
|
duration: number;
|
||||||
|
cost: number;
|
||||||
|
twilioRoomId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CallScreenProps {
|
interface CallScreenProps {
|
||||||
route?: {
|
route?: {
|
||||||
params?: {
|
params?: {
|
||||||
mode: 'ai' | 'human' | 'video' | 'sign';
|
mode?: string;
|
||||||
sourceLanguage: string;
|
sourceLanguage?: string;
|
||||||
targetLanguage: string;
|
targetLanguage?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
navigation?: any;
|
navigation?: {
|
||||||
|
goBack: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||||
@ -33,14 +60,40 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
const [callDuration, setCallDuration] = useState(0);
|
const [callDuration, setCallDuration] = useState(0);
|
||||||
const [currentCall, setCurrentCall] = useState<CallSession | null>(null);
|
const [currentCall, setCurrentCall] = useState<CallSession | null>(null);
|
||||||
|
|
||||||
|
// 计费相关状态
|
||||||
|
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||||
|
const [currentCost, setCurrentCost] = useState(0);
|
||||||
|
const [billingService] = useState(() => BillingService.getInstance());
|
||||||
|
const [lastBillingMinute, setLastBillingMinute] = useState(0);
|
||||||
|
|
||||||
const callTimer = useRef<NodeJS.Timeout | null>(null);
|
const callTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
const startTime = useRef<Date | null>(null);
|
const startTime = useRef<Date | null>(null);
|
||||||
|
const billingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 从路由参数获取通话配置
|
// 从路由参数获取通话配置
|
||||||
const callMode = route?.params?.mode || 'ai';
|
const callMode = route?.params?.mode || 'ai';
|
||||||
const sourceLanguage = route?.params?.sourceLanguage || 'zh';
|
const sourceLanguage = route?.params?.sourceLanguage || 'zh';
|
||||||
const targetLanguage = route?.params?.targetLanguage || 'en';
|
const targetLanguage = route?.params?.targetLanguage || 'en';
|
||||||
|
|
||||||
|
// 根据模式确定通话和翻译类型
|
||||||
|
const getCallType = (mode: string): CallType => {
|
||||||
|
return mode === 'video' || mode === 'sign' ? CallType.VIDEO : CallType.VOICE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTranslationType = (mode: string): TranslationType => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'sign':
|
||||||
|
return TranslationType.SIGN_LANGUAGE;
|
||||||
|
case 'human':
|
||||||
|
return TranslationType.HUMAN_INTERPRETER;
|
||||||
|
default:
|
||||||
|
return TranslationType.TEXT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const callType = getCallType(callMode);
|
||||||
|
const translationType = getTranslationType(callMode);
|
||||||
|
|
||||||
// 获取语言信息
|
// 获取语言信息
|
||||||
const getLanguageInfo = (code: string): Language | undefined => {
|
const getLanguageInfo = (code: string): Language | undefined => {
|
||||||
return mockLanguages.find(lang => lang.code === code);
|
return mockLanguages.find(lang => lang.code === code);
|
||||||
@ -49,6 +102,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
const sourceLang = getLanguageInfo(sourceLanguage);
|
const sourceLang = getLanguageInfo(sourceLanguage);
|
||||||
const targetLang = getLanguageInfo(targetLanguage);
|
const targetLang = getLanguageInfo(targetLanguage);
|
||||||
|
|
||||||
|
// 初始化用户账户
|
||||||
|
useEffect(() => {
|
||||||
|
const initUserAccount = () => {
|
||||||
|
const mockAccount: UserAccount = {
|
||||||
|
id: 'user-123',
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
balance: 10000, // 100元
|
||||||
|
};
|
||||||
|
billingService.setUserAccount(mockAccount);
|
||||||
|
setUserAccount(mockAccount);
|
||||||
|
};
|
||||||
|
|
||||||
|
initUserAccount();
|
||||||
|
}, [billingService]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 模拟连接过程
|
// 模拟连接过程
|
||||||
connectToCall();
|
connectToCall();
|
||||||
@ -57,13 +125,100 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
if (callTimer.current) {
|
if (callTimer.current) {
|
||||||
clearInterval(callTimer.current);
|
clearInterval(callTimer.current);
|
||||||
}
|
}
|
||||||
|
if (billingInterval.current) {
|
||||||
|
clearInterval(billingInterval.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 检查余额
|
||||||
|
const checkBalance = (): boolean => {
|
||||||
|
if (!userAccount) return false;
|
||||||
|
|
||||||
|
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||||
|
if (!balanceCheck.sufficient) {
|
||||||
|
Alert.alert(
|
||||||
|
'余额不足',
|
||||||
|
`需要至少 ¥${(balanceCheck.requiredAmount / 100).toFixed(2)} 才能开始通话`,
|
||||||
|
[{ text: '确定', onPress: () => navigation?.goBack() }]
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||||
|
if (shouldWarn) {
|
||||||
|
Alert.alert('提醒', '账户余额较低,请及时充值');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始计费
|
||||||
|
const startBilling = () => {
|
||||||
|
startTime.current = new Date();
|
||||||
|
|
||||||
|
// 每分钟进行计费
|
||||||
|
billingInterval.current = setInterval(() => {
|
||||||
|
if (startTime.current) {
|
||||||
|
const currentMinute = Math.floor((Date.now() - startTime.current.getTime()) / 60000);
|
||||||
|
if (currentMinute > lastBillingMinute) {
|
||||||
|
performBilling(currentMinute);
|
||||||
|
setLastBillingMinute(currentMinute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行计费
|
||||||
|
const performBilling = (minute: number) => {
|
||||||
|
if (!userAccount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cost = billingService.calculateCallCost(callType, translationType, 1);
|
||||||
|
const newTotalCost = currentCost + cost;
|
||||||
|
|
||||||
|
// 检查余额是否足够继续通话
|
||||||
|
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||||
|
if (!balanceCheck.sufficient) {
|
||||||
|
Alert.alert('余额不足', '通话即将结束', [
|
||||||
|
{ text: '确定', onPress: () => endCall() }
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣费
|
||||||
|
const deductSuccess = billingService.deductBalance(cost);
|
||||||
|
if (deductSuccess) {
|
||||||
|
setCurrentCost(newTotalCost);
|
||||||
|
const updatedAccount = billingService.getUserAccount();
|
||||||
|
setUserAccount(updatedAccount);
|
||||||
|
|
||||||
|
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||||
|
if (shouldWarn) {
|
||||||
|
Alert.alert('提醒', '账户余额较低,请及时充值');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert('扣费失败', '通话即将结束', [
|
||||||
|
{ text: '确定', onPress: () => endCall() }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('计费失败:', error);
|
||||||
|
Alert.alert('计费系统异常', '通话即将结束', [
|
||||||
|
{ text: '确定', onPress: () => endCall() }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connectToCall = async () => {
|
const connectToCall = async () => {
|
||||||
try {
|
try {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
|
|
||||||
|
// 检查余额
|
||||||
|
if (!checkBalance()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟获取Twilio token和连接过程
|
// 模拟获取Twilio token和连接过程
|
||||||
// const tokenResponse = await apiService.getTwilioToken(callMode);
|
// const tokenResponse = await apiService.getTwilioToken(callMode);
|
||||||
// const callResponse = await apiService.startCall({
|
// const callResponse = await apiService.startCall({
|
||||||
@ -77,6 +232,7 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
startCallTimer();
|
startCallTimer();
|
||||||
|
startBilling(); // 开始计费
|
||||||
|
|
||||||
// 创建模拟通话会话
|
// 创建模拟通话会话
|
||||||
const mockCall: CallSession = {
|
const mockCall: CallSession = {
|
||||||
@ -117,10 +273,14 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCost = (cents: number): string => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEndCall = () => {
|
const handleEndCall = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'结束通话',
|
'结束通话',
|
||||||
'确定要结束当前通话吗?',
|
`通话时长: ${formatDuration(callDuration)}\n费用: ¥${formatCost(currentCost)}\n确定要结束当前通话吗?`,
|
||||||
[
|
[
|
||||||
{ text: '取消', style: 'cancel' },
|
{ text: '取消', style: 'cancel' },
|
||||||
{
|
{
|
||||||
@ -139,6 +299,9 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
if (callTimer.current) {
|
if (callTimer.current) {
|
||||||
clearInterval(callTimer.current);
|
clearInterval(callTimer.current);
|
||||||
}
|
}
|
||||||
|
if (billingInterval.current) {
|
||||||
|
clearInterval(billingInterval.current);
|
||||||
|
}
|
||||||
|
|
||||||
// 在实际应用中调用API结束通话
|
// 在实际应用中调用API结束通话
|
||||||
// if (currentCall) {
|
// if (currentCall) {
|
||||||
@ -196,19 +359,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<StatusBar barStyle="light-content" backgroundColor="#1a1a1a" />
|
<StatusBar barStyle="light-content" backgroundColor="#1a1a1a" />
|
||||||
<View style={styles.connectingContainer}>
|
<View style={styles.connectingContainer}>
|
||||||
<Text style={styles.connectingIcon}>📞</Text>
|
<Text style={styles.connectingText}>正在连接...</Text>
|
||||||
<Text style={styles.connectingTitle}>正在连接...</Text>
|
<Text style={styles.modeText}>{getModeTitle(callMode)}</Text>
|
||||||
<Text style={styles.connectingSubtitle}>
|
|
||||||
{getModeTitle(callMode)}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.languageInfo}>
|
|
||||||
<Text style={styles.languageText}>
|
<Text style={styles.languageText}>
|
||||||
{sourceLang?.flag} {sourceLang?.nativeName} → {targetLang?.flag} {targetLang?.nativeName}
|
{sourceLang?.nativeName} → {targetLang?.nativeName}
|
||||||
|
</Text>
|
||||||
|
{userAccount && (
|
||||||
|
<View style={styles.balanceInfo}>
|
||||||
|
<Text style={styles.balanceText}>
|
||||||
|
账户余额: ¥{formatCost(userAccount.balance)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rateText}>
|
||||||
|
费率: ¥{formatCost(billingService.calculateCallCost(callType, translationType, 1))}/分钟
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.cancelButton} onPress={() => navigation?.goBack()}>
|
)}
|
||||||
<Text style={styles.cancelButtonText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@ -229,7 +394,15 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.callStats}>
|
||||||
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
|
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
|
||||||
|
<Text style={styles.cost}>¥{formatCost(currentCost)}</Text>
|
||||||
|
{userAccount && (
|
||||||
|
<Text style={styles.balance}>
|
||||||
|
余额: ¥{formatCost(userAccount.balance)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 视频区域 */}
|
{/* 视频区域 */}
|
||||||
@ -265,31 +438,31 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
|||||||
{/* 控制按钮 */}
|
{/* 控制按钮 */}
|
||||||
<View style={styles.controlsContainer}>
|
<View style={styles.controlsContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.controlButton, isMuted && styles.controlButtonActive]}
|
style={[styles.controlButton, isMuted && styles.mutedButton]}
|
||||||
onPress={toggleMute}
|
onPress={toggleMute}
|
||||||
>
|
>
|
||||||
<Text style={styles.controlButtonText}>
|
<Text style={styles.controlButtonText}>
|
||||||
{isMuted ? '🔇' : '🔊'}
|
{isMuted ? '🔇' : '🎤'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.endCallButton}
|
||||||
|
onPress={handleEndCall}
|
||||||
|
>
|
||||||
|
<Text style={styles.endCallButtonText}>📞</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{(callMode === 'video' || callMode === 'sign') && (
|
{(callMode === 'video' || callMode === 'sign') && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.controlButton, !isVideoEnabled && styles.controlButtonActive]}
|
style={[styles.controlButton, !isVideoEnabled && styles.mutedButton]}
|
||||||
onPress={toggleVideo}
|
onPress={toggleVideo}
|
||||||
>
|
>
|
||||||
<Text style={styles.controlButtonText}>
|
<Text style={styles.controlButtonText}>
|
||||||
{isVideoEnabled ? '📹' : '📵'}
|
{isVideoEnabled ? '📹' : '📷'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.controlButton, styles.endCallButton]}
|
|
||||||
onPress={handleEndCall}
|
|
||||||
>
|
|
||||||
<Text style={styles.controlButtonText}>📞</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@ -304,82 +477,87 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 32,
|
padding: 20,
|
||||||
},
|
},
|
||||||
connectingIcon: {
|
connectingText: {
|
||||||
fontSize: 80,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
connectingTitle: {
|
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
marginBottom: 8,
|
marginBottom: 20,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
connectingSubtitle: {
|
modeText: {
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
color: '#ccc',
|
color: '#ccc',
|
||||||
marginBottom: 32,
|
marginBottom: 10,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
languageInfo: {
|
languageText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#999',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
balanceInfo: {
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
},
|
},
|
||||||
languageText: {
|
balanceText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
cancelButton: {
|
rateText: {
|
||||||
backgroundColor: '#F44336',
|
|
||||||
paddingHorizontal: 32,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderRadius: 24,
|
|
||||||
},
|
|
||||||
cancelButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 'bold',
|
color: '#ccc',
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
topBar: {
|
topBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 16,
|
padding: 20,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
},
|
},
|
||||||
callInfo: {
|
callInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
modeIcon: {
|
modeIcon: {
|
||||||
fontSize: 32,
|
fontSize: 24,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
callDetails: {
|
callDetails: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
callTitle: {
|
callTitle: {
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
marginBottom: 2,
|
|
||||||
},
|
},
|
||||||
languagesText: {
|
languagesText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#ccc',
|
color: '#ccc',
|
||||||
},
|
},
|
||||||
|
callStats: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
duration: {
|
duration: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#4CAF50',
|
color: '#4CAF50',
|
||||||
},
|
},
|
||||||
|
balance: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#ccc',
|
||||||
|
},
|
||||||
videoContainer: {
|
videoContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
padding: 20,
|
||||||
},
|
},
|
||||||
videoArea: {
|
videoArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -388,13 +566,15 @@ const styles = StyleSheet.create({
|
|||||||
remoteVideo: {
|
remoteVideo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#333',
|
backgroundColor: '#333',
|
||||||
|
borderRadius: 12,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
localVideo: {
|
localVideo: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 16,
|
top: 20,
|
||||||
right: 16,
|
right: 20,
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 160,
|
height: 160,
|
||||||
backgroundColor: '#555',
|
backgroundColor: '#555',
|
||||||
@ -403,9 +583,8 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
videoPlaceholder: {
|
videoPlaceholder: {
|
||||||
color: '#ccc',
|
color: '#fff',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
audioOnlyArea: {
|
audioOnlyArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -413,51 +592,58 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
audioIcon: {
|
audioIcon: {
|
||||||
fontSize: 120,
|
fontSize: 80,
|
||||||
marginBottom: 24,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
audioTitle: {
|
audioTitle: {
|
||||||
fontSize: 20,
|
fontSize: 24,
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
marginBottom: 32,
|
marginBottom: 40,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
waveform: {
|
waveform: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'center',
|
||||||
height: 60,
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
},
|
},
|
||||||
waveBar: {
|
waveBar: {
|
||||||
width: 4,
|
width: 4,
|
||||||
backgroundColor: '#4CAF50',
|
backgroundColor: '#4CAF50',
|
||||||
marginHorizontal: 2,
|
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
},
|
},
|
||||||
controlsContainer: {
|
controlsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 32,
|
paddingHorizontal: 40,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
paddingBottom: 40,
|
||||||
|
gap: 40,
|
||||||
},
|
},
|
||||||
controlButton: {
|
controlButton: {
|
||||||
width: 64,
|
width: 60,
|
||||||
height: 64,
|
height: 60,
|
||||||
borderRadius: 32,
|
borderRadius: 30,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginHorizontal: 16,
|
|
||||||
},
|
},
|
||||||
controlButtonActive: {
|
mutedButton: {
|
||||||
backgroundColor: '#F44336',
|
backgroundColor: '#F44336',
|
||||||
},
|
},
|
||||||
controlButtonText: {
|
controlButtonText: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
},
|
},
|
||||||
endCallButton: {
|
endCallButton: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
backgroundColor: '#F44336',
|
backgroundColor: '#F44336',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallButtonText: {
|
||||||
|
fontSize: 32,
|
||||||
|
transform: [{ rotate: '135deg' }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
229
src/services/appointmentService.ts
Normal file
229
src/services/appointmentService.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
Appointment,
|
||||||
|
CallType,
|
||||||
|
TranslationType,
|
||||||
|
Interpreter
|
||||||
|
} from '../types/billing';
|
||||||
|
|
||||||
|
export class AppointmentService {
|
||||||
|
private static instance: AppointmentService;
|
||||||
|
private appointments: Appointment[] = [];
|
||||||
|
private interpreters: Interpreter[] = [];
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.initializeMockData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): AppointmentService {
|
||||||
|
if (!AppointmentService.instance) {
|
||||||
|
AppointmentService.instance = new AppointmentService();
|
||||||
|
}
|
||||||
|
return AppointmentService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化模拟数据
|
||||||
|
private initializeMockData() {
|
||||||
|
// 模拟翻译员数据
|
||||||
|
this.interpreters = [
|
||||||
|
{
|
||||||
|
id: 'interpreter_1',
|
||||||
|
name: '张翻译',
|
||||||
|
avatar: '👩💼',
|
||||||
|
languages: ['zh-CN', 'en-US'],
|
||||||
|
specialties: ['商务', '法律'],
|
||||||
|
rating: 4.8,
|
||||||
|
pricePerMinute: 150,
|
||||||
|
availability: {},
|
||||||
|
isOnline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interpreter_2',
|
||||||
|
name: '李翻译',
|
||||||
|
avatar: '👨💼',
|
||||||
|
languages: ['zh-CN', 'ja-JP'],
|
||||||
|
specialties: ['医疗', '技术'],
|
||||||
|
rating: 4.9,
|
||||||
|
pricePerMinute: 180,
|
||||||
|
availability: {},
|
||||||
|
isOnline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interpreter_3',
|
||||||
|
name: '王翻译',
|
||||||
|
avatar: '👩🏫',
|
||||||
|
languages: ['zh-CN', 'ko-KR'],
|
||||||
|
specialties: ['教育', '文化'],
|
||||||
|
rating: 4.7,
|
||||||
|
pricePerMinute: 120,
|
||||||
|
availability: {},
|
||||||
|
isOnline: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟预约数据
|
||||||
|
this.appointments = [
|
||||||
|
{
|
||||||
|
id: 'appointment_1',
|
||||||
|
userId: 'user_1',
|
||||||
|
title: '商务会议翻译',
|
||||||
|
description: '与韩国客户的商务洽谈',
|
||||||
|
scheduledTime: new Date(Date.now() + 24 * 60 * 60 * 1000), // 明天
|
||||||
|
duration: 60,
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.HUMAN_INTERPRETER,
|
||||||
|
interpreterIds: ['interpreter_3'],
|
||||||
|
estimatedCost: 18000, // 180元
|
||||||
|
status: 'scheduled',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'appointment_2',
|
||||||
|
userId: 'user_1',
|
||||||
|
title: '医疗咨询',
|
||||||
|
description: '日语医疗咨询翻译',
|
||||||
|
scheduledTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3天后
|
||||||
|
duration: 30,
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.HUMAN_INTERPRETER,
|
||||||
|
interpreterIds: ['interpreter_2'],
|
||||||
|
estimatedCost: 5400, // 54元
|
||||||
|
status: 'confirmed',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预约
|
||||||
|
createAppointment(appointmentData: Omit<Appointment, 'id' | 'createdAt' | 'updatedAt'>): Appointment {
|
||||||
|
const appointment: Appointment = {
|
||||||
|
...appointmentData,
|
||||||
|
id: `appointment_${Date.now()}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.appointments.push(appointment);
|
||||||
|
return appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户预约列表
|
||||||
|
getUserAppointments(userId: string): Appointment[] {
|
||||||
|
return this.appointments.filter(appointment => appointment.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定日期的预约
|
||||||
|
getAppointmentsByDate(userId: string, date: Date): Appointment[] {
|
||||||
|
const targetDate = new Date(date);
|
||||||
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
const nextDate = new Date(targetDate);
|
||||||
|
nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
|
||||||
|
return this.appointments.filter(appointment =>
|
||||||
|
appointment.userId === userId &&
|
||||||
|
appointment.scheduledTime >= targetDate &&
|
||||||
|
appointment.scheduledTime < nextDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定月份的预约
|
||||||
|
getAppointmentsByMonth(userId: string, year: number, month: number): Appointment[] {
|
||||||
|
return this.appointments.filter(appointment => {
|
||||||
|
const appointmentDate = new Date(appointment.scheduledTime);
|
||||||
|
return appointment.userId === userId &&
|
||||||
|
appointmentDate.getFullYear() === year &&
|
||||||
|
appointmentDate.getMonth() === month;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预约状态
|
||||||
|
updateAppointmentStatus(appointmentId: string, status: Appointment['status']): boolean {
|
||||||
|
const appointment = this.appointments.find(a => a.id === appointmentId);
|
||||||
|
if (appointment) {
|
||||||
|
appointment.status = status;
|
||||||
|
appointment.updatedAt = new Date();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消预约
|
||||||
|
cancelAppointment(appointmentId: string): boolean {
|
||||||
|
return this.updateAppointmentStatus(appointmentId, 'cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用翻译员
|
||||||
|
getAvailableInterpreters(
|
||||||
|
date: Date,
|
||||||
|
languages: string[],
|
||||||
|
specialty?: string
|
||||||
|
): Interpreter[] {
|
||||||
|
const dateKey = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
return this.interpreters.filter(interpreter => {
|
||||||
|
// 检查语言支持
|
||||||
|
const hasLanguage = languages.some(lang => interpreter.languages.includes(lang));
|
||||||
|
|
||||||
|
// 检查专业领域
|
||||||
|
const hasSpecialty = !specialty || interpreter.specialties.includes(specialty);
|
||||||
|
|
||||||
|
// 检查可用性(简化版,实际应该检查具体时间段)
|
||||||
|
const isAvailable = interpreter.availability[dateKey] !== false;
|
||||||
|
|
||||||
|
return hasLanguage && hasSpecialty && isAvailable;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取翻译员信息
|
||||||
|
getInterpreter(interpreterId: string): Interpreter | null {
|
||||||
|
return this.interpreters.find(interpreter => interpreter.id === interpreterId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有翻译员
|
||||||
|
getAllInterpreters(): Interpreter[] {
|
||||||
|
return this.interpreters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查时间段是否可用
|
||||||
|
isTimeSlotAvailable(date: Date, duration: number, interpreterIds?: string[]): boolean {
|
||||||
|
const endTime = new Date(date.getTime() + duration * 60 * 1000);
|
||||||
|
|
||||||
|
// 检查是否与现有预约冲突
|
||||||
|
const conflicts = this.appointments.filter(appointment => {
|
||||||
|
if (appointment.status === 'cancelled') return false;
|
||||||
|
|
||||||
|
const appointmentStart = new Date(appointment.scheduledTime);
|
||||||
|
const appointmentEnd = new Date(appointmentStart.getTime() + appointment.duration * 60 * 1000);
|
||||||
|
|
||||||
|
// 检查时间是否重叠
|
||||||
|
const timeOverlap = date < appointmentEnd && endTime > appointmentStart;
|
||||||
|
|
||||||
|
// 如果指定了翻译员,检查翻译员是否冲突
|
||||||
|
const interpreterConflict = interpreterIds && appointment.interpreterIds &&
|
||||||
|
interpreterIds.some(id => appointment.interpreterIds!.includes(id));
|
||||||
|
|
||||||
|
return timeOverlap && (!interpreterIds || interpreterConflict);
|
||||||
|
});
|
||||||
|
|
||||||
|
return conflicts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预约详情
|
||||||
|
getAppointment(appointmentId: string): Appointment | null {
|
||||||
|
return this.appointments.find(appointment => appointment.id === appointmentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取即将到来的预约
|
||||||
|
getUpcomingAppointments(userId: string, limit: number = 5): Appointment[] {
|
||||||
|
const now = new Date();
|
||||||
|
return this.appointments
|
||||||
|
.filter(appointment =>
|
||||||
|
appointment.userId === userId &&
|
||||||
|
appointment.scheduledTime > now &&
|
||||||
|
appointment.status !== 'cancelled'
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime())
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/services/billingService.ts
Normal file
217
src/services/billingService.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
UserType,
|
||||||
|
CallType,
|
||||||
|
TranslationType,
|
||||||
|
BillingRule,
|
||||||
|
UserAccount,
|
||||||
|
CallRecord,
|
||||||
|
BILLING_CONFIG
|
||||||
|
} from '../types/billing';
|
||||||
|
|
||||||
|
export class BillingService {
|
||||||
|
private static instance: BillingService;
|
||||||
|
private billingRules: BillingRule[] = [];
|
||||||
|
private userAccount: UserAccount | null = null;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.initializeDefaultRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): BillingService {
|
||||||
|
if (!BillingService.instance) {
|
||||||
|
BillingService.instance = new BillingService();
|
||||||
|
}
|
||||||
|
return BillingService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化默认计费规则
|
||||||
|
private initializeDefaultRules() {
|
||||||
|
this.billingRules = [
|
||||||
|
// 普通用户规则
|
||||||
|
{
|
||||||
|
id: 'individual_voice_text',
|
||||||
|
name: '语音通话+文字翻译',
|
||||||
|
callType: CallType.VOICE,
|
||||||
|
translationType: TranslationType.TEXT,
|
||||||
|
pricePerMinute: 50,
|
||||||
|
minimumCharge: 50,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'individual_video_sign',
|
||||||
|
name: '视频通话+手语翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.SIGN_LANGUAGE,
|
||||||
|
pricePerMinute: 100,
|
||||||
|
minimumCharge: 100,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'individual_video_human',
|
||||||
|
name: '视频通话+真人翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.HUMAN_INTERPRETER,
|
||||||
|
pricePerMinute: 200,
|
||||||
|
minimumCharge: 200,
|
||||||
|
userType: UserType.INDIVIDUAL,
|
||||||
|
},
|
||||||
|
// 企业用户规则(相同但可能有优惠)
|
||||||
|
{
|
||||||
|
id: 'enterprise_voice_text',
|
||||||
|
name: '语音通话+文字翻译',
|
||||||
|
callType: CallType.VOICE,
|
||||||
|
translationType: TranslationType.TEXT,
|
||||||
|
pricePerMinute: 40, // 企业优惠价
|
||||||
|
minimumCharge: 40,
|
||||||
|
userType: UserType.ENTERPRISE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise_video_sign',
|
||||||
|
name: '视频通话+手语翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.SIGN_LANGUAGE,
|
||||||
|
pricePerMinute: 80,
|
||||||
|
minimumCharge: 80,
|
||||||
|
userType: UserType.ENTERPRISE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise_video_human',
|
||||||
|
name: '视频通话+真人翻译',
|
||||||
|
callType: CallType.VIDEO,
|
||||||
|
translationType: TranslationType.HUMAN_INTERPRETER,
|
||||||
|
pricePerMinute: 160,
|
||||||
|
minimumCharge: 160,
|
||||||
|
userType: UserType.ENTERPRISE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置用户账户
|
||||||
|
setUserAccount(account: UserAccount) {
|
||||||
|
this.userAccount = account;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户账户
|
||||||
|
getUserAccount(): UserAccount | null {
|
||||||
|
return this.userAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取计费规则
|
||||||
|
getBillingRule(callType: CallType, translationType: TranslationType, userType: UserType): BillingRule | null {
|
||||||
|
return this.billingRules.find(rule =>
|
||||||
|
rule.callType === callType &&
|
||||||
|
rule.translationType === translationType &&
|
||||||
|
rule.userType === userType
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算通话费用
|
||||||
|
calculateCallCost(
|
||||||
|
callType: CallType,
|
||||||
|
translationType: TranslationType,
|
||||||
|
durationMinutes: number,
|
||||||
|
interpreterRate?: number
|
||||||
|
): number {
|
||||||
|
if (!this.userAccount) return 0;
|
||||||
|
|
||||||
|
const rule = this.getBillingRule(callType, translationType, this.userAccount.userType);
|
||||||
|
if (!rule) return 0;
|
||||||
|
|
||||||
|
// 向上取整分钟数
|
||||||
|
const roundedMinutes = Math.ceil(durationMinutes);
|
||||||
|
|
||||||
|
// 基础费用
|
||||||
|
let baseCost = Math.max(roundedMinutes * rule.pricePerMinute, rule.minimumCharge);
|
||||||
|
|
||||||
|
// 如果有翻译员费用,额外计算
|
||||||
|
let interpreterCost = 0;
|
||||||
|
if (interpreterRate && translationType === TranslationType.HUMAN_INTERPRETER) {
|
||||||
|
interpreterCost = roundedMinutes * interpreterRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseCost + interpreterCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查余额是否足够
|
||||||
|
checkBalance(
|
||||||
|
callType: CallType,
|
||||||
|
translationType: TranslationType,
|
||||||
|
minutes: number = 1,
|
||||||
|
interpreterRate?: number
|
||||||
|
): {
|
||||||
|
sufficient: boolean;
|
||||||
|
requiredAmount: number;
|
||||||
|
currentBalance: number;
|
||||||
|
} {
|
||||||
|
if (!this.userAccount) {
|
||||||
|
return { sufficient: false, requiredAmount: 0, currentBalance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredAmount = this.calculateCallCost(callType, translationType, minutes, interpreterRate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sufficient: this.userAccount.balance >= requiredAmount,
|
||||||
|
requiredAmount,
|
||||||
|
currentBalance: this.userAccount.balance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要低余额警告
|
||||||
|
shouldShowLowBalanceWarning(
|
||||||
|
callType: CallType,
|
||||||
|
translationType: TranslationType,
|
||||||
|
interpreterRate?: number
|
||||||
|
): boolean {
|
||||||
|
const thresholdCost = this.calculateCallCost(
|
||||||
|
callType,
|
||||||
|
translationType,
|
||||||
|
BILLING_CONFIG.LOW_BALANCE_THRESHOLD_MINUTES,
|
||||||
|
interpreterRate
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.userAccount ? this.userAccount.balance < thresholdCost : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣费
|
||||||
|
deductBalance(amount: number): boolean {
|
||||||
|
if (!this.userAccount || this.userAccount.balance < amount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userAccount.balance -= amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值
|
||||||
|
recharge(amount: number): void {
|
||||||
|
if (this.userAccount) {
|
||||||
|
this.userAccount.balance += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化金额(分转元)
|
||||||
|
formatAmount(cents: number): string {
|
||||||
|
return `¥${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有计费规则
|
||||||
|
getAllBillingRules(): BillingRule[] {
|
||||||
|
return this.billingRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户可用的计费规则
|
||||||
|
getUserBillingRules(): BillingRule[] {
|
||||||
|
if (!this.userAccount) return [];
|
||||||
|
return this.billingRules.filter(rule => rule.userType === this.userAccount!.userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值账户
|
||||||
|
rechargeAccount(amount: number): boolean {
|
||||||
|
if (!this.userAccount || amount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userAccount.balance += amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/types/billing.ts
Normal file
121
src/types/billing.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// 用户类型
|
||||||
|
export enum UserType {
|
||||||
|
INDIVIDUAL = 'individual', // 普通用户
|
||||||
|
ENTERPRISE = 'enterprise', // 企业用户
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话类型
|
||||||
|
export enum CallType {
|
||||||
|
VOICE = 'voice', // 语音通话
|
||||||
|
VIDEO = 'video', // 视频通话
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译类型
|
||||||
|
export enum TranslationType {
|
||||||
|
TEXT = 'text', // 文字翻译
|
||||||
|
SIGN_LANGUAGE = 'sign_language', // 手语翻译
|
||||||
|
HUMAN_INTERPRETER = 'human_interpreter', // 真人翻译
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费规则
|
||||||
|
export interface BillingRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
pricePerMinute: number; // 每分钟价格(分)
|
||||||
|
minimumCharge: number; // 最低收费(分)
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户账户信息
|
||||||
|
export interface UserAccount {
|
||||||
|
id: string;
|
||||||
|
userType: UserType;
|
||||||
|
balance: number; // 余额(分)
|
||||||
|
enterpriseContractId?: string; // 企业合同ID
|
||||||
|
creditLimit?: number; // 信用额度(分)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预约信息
|
||||||
|
export interface Appointment {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
scheduledTime: Date;
|
||||||
|
duration: number; // 预计时长(分钟)
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
interpreterIds?: string[]; // 翻译员ID列表
|
||||||
|
estimatedCost: number; // 预估费用(分)
|
||||||
|
status: 'scheduled' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译员信息
|
||||||
|
export interface Interpreter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
languages: string[]; // 支持的语言
|
||||||
|
specialties: string[]; // 专业领域
|
||||||
|
rating: number; // 评分
|
||||||
|
pricePerMinute: number; // 每分钟价格(分)
|
||||||
|
availability: {
|
||||||
|
[key: string]: boolean; // 日期可用性
|
||||||
|
};
|
||||||
|
isOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话记录
|
||||||
|
export interface CallRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
appointmentId?: string;
|
||||||
|
callType: CallType;
|
||||||
|
translationType: TranslationType;
|
||||||
|
interpreterIds?: string[];
|
||||||
|
startTime: Date;
|
||||||
|
endTime?: Date;
|
||||||
|
duration: number; // 实际时长(分钟)
|
||||||
|
cost: number; // 实际费用(分)
|
||||||
|
status: 'in_progress' | 'completed' | 'failed';
|
||||||
|
billingDetails: {
|
||||||
|
baseRate: number;
|
||||||
|
interpreterRate?: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值记录
|
||||||
|
export interface RechargeRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
amount: number; // 充值金额(分)
|
||||||
|
bonus: number; // 赠送金额(分)
|
||||||
|
paymentMethod: string;
|
||||||
|
status: 'pending' | 'completed' | 'failed';
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计费配置
|
||||||
|
export const BILLING_CONFIG = {
|
||||||
|
// 默认计费规则
|
||||||
|
DEFAULT_RATES: {
|
||||||
|
[CallType.VOICE]: {
|
||||||
|
[TranslationType.TEXT]: 50, // 0.5元/分钟
|
||||||
|
},
|
||||||
|
[CallType.VIDEO]: {
|
||||||
|
[TranslationType.SIGN_LANGUAGE]: 100, // 1元/分钟
|
||||||
|
[TranslationType.HUMAN_INTERPRETER]: 200, // 2元/分钟
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 低余额警告阈值(5分钟费用)
|
||||||
|
LOW_BALANCE_THRESHOLD_MINUTES: 5,
|
||||||
|
// 最低余额阈值(1分钟费用)
|
||||||
|
MINIMUM_BALANCE_THRESHOLD_MINUTES: 1,
|
||||||
|
};
|
||||||
63
src/utils/deviceDetection.ts
Normal file
63
src/utils/deviceDetection.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 检测是否为移动设备
|
||||||
|
*/
|
||||||
|
export const isMobileDevice = (): boolean => {
|
||||||
|
// 检查用户代理字符串
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const mobileKeywords = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||||
|
const isMobileUA = mobileKeywords.test(userAgent);
|
||||||
|
|
||||||
|
// 检查屏幕尺寸
|
||||||
|
const isSmallScreen = window.innerWidth <= 768;
|
||||||
|
|
||||||
|
// 检查触摸支持
|
||||||
|
const hasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
|
return isMobileUA || (isSmallScreen && hasTouchSupport);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为平板设备
|
||||||
|
*/
|
||||||
|
export const isTabletDevice = (): boolean => {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const tabletKeywords = /iPad|Android(?!.*Mobile)/i;
|
||||||
|
const isTabletUA = tabletKeywords.test(userAgent);
|
||||||
|
|
||||||
|
const isMediumScreen = window.innerWidth > 768 && window.innerWidth <= 1024;
|
||||||
|
|
||||||
|
return isTabletUA || isMediumScreen;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备类型
|
||||||
|
*/
|
||||||
|
export const getDeviceType = (): 'mobile' | 'tablet' | 'desktop' => {
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
return 'mobile';
|
||||||
|
}
|
||||||
|
if (isTabletDevice()) {
|
||||||
|
return 'tablet';
|
||||||
|
}
|
||||||
|
return 'desktop';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取推荐的路由路径
|
||||||
|
*/
|
||||||
|
export const getRecommendedRoute = (): string => {
|
||||||
|
// 暂时让所有设备都重定向到移动端首页,方便测试
|
||||||
|
return '/mobile/home';
|
||||||
|
|
||||||
|
// 原来的逻辑保留备用
|
||||||
|
// const deviceType = getDeviceType();
|
||||||
|
//
|
||||||
|
// switch (deviceType) {
|
||||||
|
// case 'mobile':
|
||||||
|
// case 'tablet':
|
||||||
|
// return '/mobile/home';
|
||||||
|
// case 'desktop':
|
||||||
|
// default:
|
||||||
|
// return '/dashboard';
|
||||||
|
// }
|
||||||
|
};
|
||||||
69
start-servers.ps1
Normal file
69
start-servers.ps1
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Twilio视频通话服务启动脚本
|
||||||
|
Write-Host "🚀 启动 Twilio 视频通话服务..." -ForegroundColor Green
|
||||||
|
|
||||||
|
# 检查Node.js是否安装
|
||||||
|
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "❌ 错误: 未找到 Node.js,请先安装 Node.js" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查npm是否安装
|
||||||
|
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "❌ 错误: 未找到 npm,请先安装 npm" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "📦 检查并安装依赖..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
Write-Host "安装前端依赖..." -ForegroundColor Cyan
|
||||||
|
if (-not (Test-Path "node_modules")) {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
|
||||||
|
# 安装后端依赖
|
||||||
|
Write-Host "安装后端依赖..." -ForegroundColor Cyan
|
||||||
|
if (-not (Test-Path "server/node_modules")) {
|
||||||
|
Set-Location server
|
||||||
|
npm install
|
||||||
|
Set-Location ..
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "🎬 启动服务器..." -ForegroundColor Green
|
||||||
|
|
||||||
|
# 启动后端Token服务器
|
||||||
|
Write-Host "启动 Twilio Token 服务器 (端口 3001)..." -ForegroundColor Cyan
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd server; npm start" -WindowStyle Normal
|
||||||
|
|
||||||
|
# 等待2秒让后端启动
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# 启动前端开发服务器
|
||||||
|
Write-Host "启动前端开发服务器 (端口 5175)..." -ForegroundColor Cyan
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "npm run dev" -WindowStyle Normal
|
||||||
|
|
||||||
|
# 等待3秒让服务器启动
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ 服务启动完成!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 访问地址:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 前端应用: http://localhost:5175" -ForegroundColor White
|
||||||
|
Write-Host " 视频测试: http://localhost:5175/video-test" -ForegroundColor White
|
||||||
|
Write-Host " 后端API: http://localhost:3001" -ForegroundColor White
|
||||||
|
Write-Host " 健康检查: http://localhost:3001/health" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🧪 测试步骤:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. 访问 http://localhost:5175/video-test" -ForegroundColor White
|
||||||
|
Write-Host " 2. 填写房间名称和用户身份" -ForegroundColor White
|
||||||
|
Write-Host " 3. 点击'加入通话'按钮" -ForegroundColor White
|
||||||
|
Write-Host " 4. 在新标签页中重复步骤1-3测试多人通话" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ 注意事项:" -ForegroundColor Red
|
||||||
|
Write-Host " - 确保已配置正确的 Twilio 凭证" -ForegroundColor White
|
||||||
|
Write-Host " - 允许浏览器访问摄像头和麦克风" -ForegroundColor White
|
||||||
|
Write-Host " - 使用 HTTPS 或 localhost 以获得最佳体验" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "按任意键退出..." -ForegroundColor Gray
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
63
start-services.ps1
Normal file
63
start-services.ps1
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Twilio 视频通话服务启动脚本
|
||||||
|
# 使用方法: .\start-services.ps1
|
||||||
|
|
||||||
|
Write-Host "🚀 启动 Twilio 视频通话服务..." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 检查 Node.js 是否安装
|
||||||
|
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "❌ 错误: 未找到 Node.js,请先安装 Node.js" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 npm 是否安装
|
||||||
|
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "❌ 错误: 未找到 npm,请先安装 npm" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✅ Node.js 和 npm 已安装" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 启动后端服务器
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 启动后端 Token 服务器..." -ForegroundColor Yellow
|
||||||
|
Write-Host "端口: 3001" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd server; npm install; npm start" -WindowStyle Normal
|
||||||
|
|
||||||
|
# 等待后端服务器启动
|
||||||
|
Write-Host "⏳ 等待后端服务器启动..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# 启动前端应用
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 启动前端应用..." -ForegroundColor Yellow
|
||||||
|
Write-Host "端口: 5173" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "npm install; npm run dev" -WindowStyle Normal
|
||||||
|
|
||||||
|
# 等待前端应用启动
|
||||||
|
Write-Host "⏳ 等待前端应用启动..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🎉 服务启动完成!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 访问地址:" -ForegroundColor Cyan
|
||||||
|
Write-Host " • 前端应用: http://localhost:5173" -ForegroundColor White
|
||||||
|
Write-Host " • 后端 API: http://localhost:3001" -ForegroundColor White
|
||||||
|
Write-Host " • 健康检查: http://localhost:3001/health" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🧪 测试页面:" -ForegroundColor Cyan
|
||||||
|
Write-Host " • 设备测试: http://localhost:5173/device-test" -ForegroundColor White
|
||||||
|
Write-Host " • 视频通话: http://localhost:5173/video-call" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ 注意事项:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. 确保已配置正确的 Twilio 凭证" -ForegroundColor White
|
||||||
|
Write-Host " 2. 浏览器需要允许摄像头和麦克风权限" -ForegroundColor White
|
||||||
|
Write-Host " 3. 建议使用 Chrome 浏览器进行测试" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📖 详细测试指南请查看: TWILIO_TEST_GUIDE.md" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "按任意键退出..." -ForegroundColor Gray
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
263
test-status.html
Normal file
263
test-status.html
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Twilio 视频通话服务状态检查</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.status-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
.status-indicator {
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
.status-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.status-pending {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.test-button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.test-button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
.test-button:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.info-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.info-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
.info-section ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.info-section li {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
.quick-links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.quick-link {
|
||||||
|
display: block;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.quick-link:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎥 Twilio 视频通话服务状态</h1>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">后端 Token 服务器 (localhost:3001)</span>
|
||||||
|
<div>
|
||||||
|
<span id="backend-status" class="status-indicator status-pending">检查中...</span>
|
||||||
|
<button class="test-button" onclick="checkBackend()">重新检查</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">前端应用 (localhost:5173)</span>
|
||||||
|
<div>
|
||||||
|
<span id="frontend-status" class="status-indicator status-pending">检查中...</span>
|
||||||
|
<button class="test-button" onclick="checkFrontend()">重新检查</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">摄像头权限</span>
|
||||||
|
<div>
|
||||||
|
<span id="camera-status" class="status-indicator status-pending">未检查</span>
|
||||||
|
<button class="test-button" onclick="checkCamera()">检查权限</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">麦克风权限</span>
|
||||||
|
<div>
|
||||||
|
<span id="microphone-status" class="status-indicator status-pending">未检查</span>
|
||||||
|
<button class="test-button" onclick="checkMicrophone()">检查权限</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h3>📋 快速测试指南</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>步骤 1:</strong> 确保所有服务状态显示为"正常"</li>
|
||||||
|
<li><strong>步骤 2:</strong> 点击"检查权限"按钮允许摄像头和麦克风访问</li>
|
||||||
|
<li><strong>步骤 3:</strong> 使用下方的快速链接访问测试页面</li>
|
||||||
|
<li><strong>步骤 4:</strong> 在两个不同的浏览器标签页中测试多人通话</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-links">
|
||||||
|
<a href="http://localhost:5173" class="quick-link">🏠 主应用</a>
|
||||||
|
<a href="http://localhost:5173/device-test" class="quick-link">🔧 设备测试</a>
|
||||||
|
<a href="http://localhost:5173/video-call" class="quick-link">📹 视频通话</a>
|
||||||
|
<a href="http://localhost:3001/health" class="quick-link">💚 服务器状态</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h3>⚠️ 注意事项</h3>
|
||||||
|
<ul>
|
||||||
|
<li>确保在 <code>server/index.js</code> 中配置了正确的 Twilio 凭证</li>
|
||||||
|
<li>建议使用 Chrome 浏览器进行测试</li>
|
||||||
|
<li>如果遇到 CORS 错误,请检查服务器配置</li>
|
||||||
|
<li>生产环境需要 HTTPS 才能访问摄像头和麦克风</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 检查后端服务器状态
|
||||||
|
async function checkBackend() {
|
||||||
|
const statusElement = document.getElementById('backend-status');
|
||||||
|
statusElement.textContent = '检查中...';
|
||||||
|
statusElement.className = 'status-indicator status-pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3001/health');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
statusElement.textContent = '正常';
|
||||||
|
statusElement.className = 'status-indicator status-success';
|
||||||
|
} else {
|
||||||
|
throw new Error('服务器响应错误');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = '无法连接';
|
||||||
|
statusElement.className = 'status-indicator status-error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查前端应用状态
|
||||||
|
async function checkFrontend() {
|
||||||
|
const statusElement = document.getElementById('frontend-status');
|
||||||
|
statusElement.textContent = '检查中...';
|
||||||
|
statusElement.className = 'status-indicator status-pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:5173');
|
||||||
|
if (response.ok) {
|
||||||
|
statusElement.textContent = '正常';
|
||||||
|
statusElement.className = 'status-indicator status-success';
|
||||||
|
} else {
|
||||||
|
throw new Error('前端应用响应错误');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = '无法连接';
|
||||||
|
statusElement.className = 'status-indicator status-error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查摄像头权限
|
||||||
|
async function checkCamera() {
|
||||||
|
const statusElement = document.getElementById('camera-status');
|
||||||
|
statusElement.textContent = '检查中...';
|
||||||
|
statusElement.className = 'status-indicator status-pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
statusElement.textContent = '已授权';
|
||||||
|
statusElement.className = 'status-indicator status-success';
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = '权限被拒绝';
|
||||||
|
statusElement.className = 'status-indicator status-error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查麦克风权限
|
||||||
|
async function checkMicrophone() {
|
||||||
|
const statusElement = document.getElementById('microphone-status');
|
||||||
|
statusElement.textContent = '检查中...';
|
||||||
|
statusElement.className = 'status-indicator status-pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
statusElement.textContent = '已授权';
|
||||||
|
statusElement.className = 'status-indicator status-success';
|
||||||
|
} catch (error) {
|
||||||
|
statusElement.textContent = '权限被拒绝';
|
||||||
|
statusElement.className = 'status-indicator status-error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时自动检查服务状态
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
checkBackend();
|
||||||
|
checkFrontend();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user