Compare commits

...

3 Commits

66 changed files with 16185 additions and 713 deletions
+15
View File
@@ -0,0 +1,15 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "packager-info.json": contains port numbers and process PIDs that are used to serve the application to the mobile device/simulator.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
+8
View File
@@ -0,0 +1,8 @@
{
"hostType": "lan",
"lanType": "ip",
"dev": true,
"minify": false,
"urlRandomness": null,
"https": false
}
+34 -12
View File
@@ -1,19 +1,41 @@
import React from 'react';
import { Provider } from 'react-redux';
import { StatusBar } from 'react-native';
import { store } from '@/store';
import AppNavigator from '@/navigation/AppNavigator';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import './src/styles/global.css';
// 导入页面组件
import HomeScreen from './src/screens/HomeScreen';
import CallScreen from './src/screens/CallScreen';
import DocumentScreen from './src/screens/DocumentScreen';
import SettingsScreen from './src/screens/SettingsScreen';
// 导入移动端导航组件
import MobileNavigation from './src/components/MobileNavigation.web';
const App: React.FC = () => {
return (
<Provider store={store}>
<StatusBar
barStyle="dark-content"
backgroundColor="#fff"
translucent={false}
/>
<AppNavigator />
</Provider>
<ConfigProvider locale={zhCN}>
<Router
future={{
v7_startTransition: true,
v7_relativeSplatPath: true
}}
>
<div className="app-container">
<div className="app-content">
<Routes>
<Route path="/" element={<Navigate to="/home" replace />} />
<Route path="/home" element={<HomeScreen />} />
<Route path="/call" element={<CallScreen />} />
<Route path="/documents" element={<DocumentScreen />} />
<Route path="/settings" element={<SettingsScreen />} />
</Routes>
</div>
<MobileNavigation />
</div>
</Router>
</ConfigProvider>
);
};
+130
View 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. **单元测试**: 为核心计费逻辑添加测试用例
+111 -162
View File
@@ -1,187 +1,136 @@
# Twilio 翻译服务管理系统 - 项目状态报告
# Twilio翻译应用项目状态总结
## 🎉 项目部署状态
## 项目概述
本项目是一个基于Twilio的实时翻译应用,包含移动端和后端管理系统两个部分。
**✅ 成功部署并运行**
- **部署时间**: 2024年1月15日
- **访问地址**: http://localhost:3000
- **状态**: 开发服务器正在运行中
## ✅ 已完成功能
## 🔧 已解决的技术问题
### 🚀 项目架构
- ✅ 移动端项目(React + Vite + TypeScript
- ✅ 后端管理系统(React + Vite + TypeScript
- ✅ Twilio视频通话服务集成
- ✅ 响应式设计和移动端适配
### 1. React 导入问题修复
-移除了不必要的 `import React from 'react'` 语句
-修复了 JSX 转换配置问题
-更新了组件类型定义
### 📱 移动端功能
-路由系统配置(React Router
-移动端导航栏
-视频通话页面 (`/mobile/video-call`)
- ✅ 首页、通话、文档、预约、设置页面
- ✅ Ant Design UI组件库集成
### 2. TypeScript 配置优化
-配置了 `jsx: "react-jsx"` 支持新的 JSX 转换
-修复了类型定义错误
-解决了模块导入问题
### 🎥 视频通话功能
-VideoCall组件实现
-VideoCallPage页面
-房间名称和用户身份输入
- ✅ 音频/视频控制开关
- ✅ 参与者管理和显示
- ✅ 实时连接状态管理
### 3. 组件架构修复
**已修复的文件:**
-`src/main.tsx` - 入口文件
-`src/App.tsx` - 主应用组件
-`src/routes/index.tsx` - 路由配置
-`src/components/Layout/AppLayout.tsx` - 布局组件
-`src/components/Layout/AppSidebar.tsx` - 侧边栏组件
-`src/components/Layout/AppHeader.tsx` - 头部组件
-`src/pages/Dashboard/index.tsx` - 仪表板页面
-`src/pages/Users/UserList.tsx` - 用户列表页面
-`src/store/index.ts` - 状态管理
### 🔧 Twilio服务集成
- ✅ TwilioService类实现
-Token服务器配置
-配置文件设置
-API接口定义
### 4. 状态管理系统
-完整的 React Context + useReducer 架构
-模块化的 hooks 设计
-支持主题切换、用户认证、通知系统
### 💻 后端管理系统
-管理界面框架
-用户管理页面
-通话记录管理
- ✅ 仪表板统计
- ✅ Token生成服务
## 📊 项目核心功能
### 🛠️ 开发环境
- ✅ 两个服务同时运行
- 移动端:http://localhost:3000
- 后端管理:http://localhost:3001
- ✅ 热重载开发环境
- ✅ TypeScript类型检查
- ✅ ESLint代码规范
### 已实现功能
1. **仪表板 (Dashboard)**
- 统计数据展示
- 最近通话记录
- 系统状态监控
## 🔄 当前运行状态
- ✅ 移动端服务:端口3000 - 正常运行
- ✅ 后端管理服务:端口3001 - 正常运行
- ✅ 路由系统:正常工作
- ✅ 导航系统:正常工作
2. **用户管理 (User Management)**
- 用户列表展示
- 用户添加/编辑/删除
- 角色权限管理
- 状态管理
## 📝 配置说明
3. **布局系统**
- 响应式侧边栏
- 主题切换功能
- 通知系统
- 用户菜单
### Twilio配置
需要在 `src/config/twilio.ts` 中配置真实的Twilio凭证:
```typescript
export const twilioConfig: TwilioConfig = {
apiKey: 'YOUR_API_KEY', // 替换为真实API Key
apiSecret: 'YOUR_API_SECRET', // 替换为真实API Secret
accountSid: 'YOUR_ACCOUNT_SID', // 替换为真实Account SID
};
```
4. **路由系统**
- 公共路由和私有路由
- 权限控制
- 404 页面处理
## 🌟 主要特性
### 技术栈
- **前端框架**: React 18 + TypeScript
- **UI 组件库**: Ant Design 5.x
- **状态管理**: React Context + useReducer
- **路由管理**: React Router v6
- **构建工具**: Vite
- **样式处理**: CSS-in-JS + Ant Design 主题
### 移动端导航
- 🏠 首页 (`/mobile/home`)
- 📞 通话 (`/mobile/call`)
- 📹 视频通话 (`/mobile/video-call`) - **新增功能**
- 📄 文档 (`/mobile/documents`)
- 📅 预约 (`/mobile/appointments`)
- ⚙️ 设置 (`/mobile/settings`)
## 🚀 快速开始
### 视频通话功能
- 房间创建和加入
- 实时音视频传输
- 参与者管理
- 音频/视频开关控制
- 连接状态监控
### 访问应用
1. 打开浏览器访问: http://localhost:3000
2. 应用已启动,可以直接使用
## 🔧 技术栈
### 开发命令
### 前端
- React 18
- TypeScript
- Vite
- Ant Design
- React Router
- Twilio Video SDK
### 后端服务
- Express.jsToken服务器)
- JWT Token生成
- Twilio REST API
## 📖 使用指南
### 启动服务
```bash
# 启动开发服务器
# 启动移动端
npm run dev
# 构建生产版本
npm run build
# 预览生产构建
npm run preview
# 类型检查
npm run type-check
# 启动后端管理系统
cd Twilioapp-admin && npm start
```
## 📁 项目结构
### 访问应用
- 移动端:http://localhost:3000/mobile/video-call
- 后端管理:http://localhost:3001
```
src/
├── components/ # 公共组件
│ └── Layout/ # 布局组件
├── pages/ # 页面组件
│ ├── Dashboard/ # 仪表板
│ └── Users/ # 用户管理
├── routes/ # 路由配置
├── store/ # 状态管理
├── types/ # 类型定义
├── utils/ # 工具函数
├── constants/ # 常量定义
├── services/ # API 服务
├── main.tsx # 应用入口
└── App.tsx # 主应用组件
```
### 测试视频通话
1. 打开移动端视频通话页面
2. 输入房间名称(如:test-room)
3. 输入用户身份(如:user1
4. 点击"加入通话"
5. 多个用户使用相同房间名称即可加入同一通话
## 🎯 下一步开发计划
## ⚠️ 注意事项
- 需要配置真实的Twilio凭证才能使用视频通话功能
- 浏览器需要允许摄像头和麦克风权限
- 建议使用HTTPS环境进行生产部署
### 待开发功能
1. **通话记录管理**
- 通话记录列表
- 通话详情查看
- 通话统计分析
## 📚 文档
- [Twilio配置指南](./TWILIO_SETUP.md)
- [API接口文档](./API_DOCS.md)
2. **文档翻译系统**
- 文档上传
- 翻译进度跟踪
- 翻译质量评估
3. **预约管理系统**
- 预约创建和管理
- 日历视图
- 提醒通知
4. **译员管理系统**
- 译员资料管理
- 技能评级
- 工作安排
5. **财务管理系统**
- 收费标准设置
- 账单生成
- 支付记录
## 🔍 技术特点
### 代码质量
- ✅ TypeScript 严格模式
- ✅ ESLint 代码规范
- ✅ 组件化架构
- ✅ 响应式设计
### 性能优化
- ✅ Vite 快速构建
- ✅ 代码分割
- ✅ 懒加载路由
- ✅ 组件缓存
### 用户体验
- ✅ 现代化 UI 设计
- ✅ 主题切换支持
- ✅ 移动端适配
- ✅ 加载状态处理
## 📈 项目统计
- **总文件数**: 50+
- **代码行数**: 5000+
- **依赖包数**: 30+
- **组件数量**: 20+
- **页面数量**: 10+
- **工具函数**: 15+
- **类型定义**: 50+
## ✨ 项目亮点
1. **零错误启动**: 所有导入和类型错误已修复
2. **现代化架构**: 使用最新的 React 和 TypeScript 特性
3. **完整的状态管理**: 统一的状态管理系统
4. **响应式设计**: 支持各种屏幕尺寸
5. **主题系统**: 支持明暗主题切换
6. **类型安全**: 完整的 TypeScript 类型定义
## 🎊 总结
项目已成功修复所有技术问题并正常运行!现在您可以:
1. **立即访问**: 打开 http://localhost:3000 查看应用
2. **开始开发**: 基于现有架构继续开发新功能
3. **自定义配置**: 根据需求调整主题和配置
所有核心功能都已就绪,开发环境稳定运行。祝您开发愉快!🚀
## 🎯 下一步计划
- 完善用户认证系统
- 添加聊天消息功能
- 实现屏幕共享
- 添加录制功能
- 优化移动端UI/UX
+128
View File
@@ -0,0 +1,128 @@
# Twilio 视频通话服务配置指南
## 概述
本项目集成了Twilio视频通话服务,支持移动端和Web端的实时视频通话功能。
## 前置条件
1. 注册Twilio账户:https://www.twilio.com/
2. 获取必要的API凭证
## 配置步骤
### 1. 获取Twilio凭证
登录Twilio控制台,获取以下信息:
- Account SID
- API Key
- API Secret
### 2. 更新配置文件
编辑 `src/config/twilio.ts` 文件,替换以下配置:
```typescript
export const twilioConfig: TwilioConfig = {
apiKey: 'YOUR_API_KEY', // 替换为您的API Key
apiSecret: 'YOUR_API_SECRET', // 替换为您的API Secret
accountSid: 'YOUR_ACCOUNT_SID', // 替换为您的Account SID
videoServiceSid: '', // 可选
conversationServiceSid: '', // 可选
};
```
### 3. 启动服务
```bash
# 启动移动端(端口3000
npm run dev
# 启动后端管理系统(端口3001
cd Twilioapp-admin && npm start
```
## 功能特性
### 移动端功能
- 视频通话页面:`/mobile/video-call`
- 支持房间名称和用户身份输入
- 音频/视频开关控制
- 实时参与者显示
### 后端管理功能
- Token服务器:生成访问令牌
- 通话记录管理
- 用户管理
## 使用方法
### 1. 访问视频通话
- 移动端:http://localhost:3000/mobile/video-call
- 输入房间名称和用户身份
- 点击"加入通话"
### 2. 多人通话
- 多个用户使用相同房间名称即可加入同一通话
- 支持音频/视频开关控制
- 实时显示参与者状态
## 技术架构
### 前端技术栈
- React 18
- TypeScript
- Ant Design
- Twilio Video SDK
- React Router
### 后端技术栈
- Express.js
- JWT Token生成
- Twilio REST API
## API接口
### Token生成接口
```
POST /api/twilio/token
Content-Type: application/json
{
"identity": "用户身份",
"roomName": "房间名称"
}
Response:
{
"token": "访问令牌",
"identity": "用户身份",
"roomName": "房间名称"
}
```
## 故障排除
### 常见问题
1. **无法连接到房间**
- 检查API凭证是否正确
- 确认Token服务器正常运行
2. **音视频无法正常工作**
- 检查浏览器权限设置
- 确认摄像头和麦克风可用
3. **Token验证失败**
- 检查API Key和Secret是否匹配
- 确认Account SID正确
### 调试模式
开启浏览器开发者工具查看控制台日志,所有Twilio相关错误都会在控制台显示。
## 安全注意事项
- 不要在客户端代码中暴露API Secret
- 生产环境请使用HTTPS
- 定期更新API凭证
- 实施适当的用户认证机制
## 扩展功能
- 屏幕共享
- 录制功能
- 聊天消息
- 用户权限管理
- 通话质量监控
+247
View 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. 监控服务器性能
---
**注意**: 这是一个测试环境配置,生产环境需要额外的安全和性能优化措施。
+900 -7
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -3,19 +3,25 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@twilio/conversations": "^2.6.2",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.56",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"antd": "^5.0.0",
"dayjs": "^1.11.13",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"recharts": "^3.0.2",
"twilio": "^5.7.1",
"twilio-video": "^2.31.0",
"typescript": "^4.7.4",
"web-vitals": "^2.1.4"
},
@@ -46,4 +52,4 @@
"devDependencies": {
"@types/moment": "^2.13.0"
}
}
}
+169 -73
View File
@@ -1,11 +1,18 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { Layout, Menu, ConfigProvider } from 'antd';
import {
DashboardOutlined,
PhoneOutlined,
FileTextOutlined,
CalendarOutlined
import { BrowserRouter as Router, Routes, Route, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, ConfigProvider } from 'antd';
import {
DashboardOutlined,
PhoneOutlined,
FileTextOutlined,
CalendarOutlined,
UserOutlined,
TeamOutlined,
DollarOutlined,
SettingOutlined,
WalletOutlined,
BarChartOutlined,
CalculatorOutlined
} from '@ant-design/icons';
import zhCN from 'antd/locale/zh_CN';
import 'antd/dist/reset.css';
@@ -13,112 +20,201 @@ import './App.css';
// 导入页面组件
import Dashboard from './pages/Dashboard';
import CallList from './pages/Calls/CallList';
import CallDetail from './pages/Calls/CallDetail';
import DocumentList from './pages/Documents/DocumentList';
import DocumentDetail from './pages/Documents/DocumentDetail';
import AppointmentList from './pages/Appointments/AppointmentList';
import AppointmentDetail from './pages/Appointments/AppointmentDetail';
import UserList from './pages/Users/UserList';
import TranslatorList from './pages/Translators/TranslatorList';
import PaymentList from './pages/Payments/PaymentList';
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 { Title } = Typography;
const AppContent: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const [selectedKey, setSelectedKey] = useState('1');
const handleMenuClick = (e: any) => {
setSelectedKey(e.key);
switch (e.key) {
case '1':
navigate('/dashboard');
const handleMenuClick = ({ key }: { key: string }) => {
switch (key) {
case 'dashboard':
navigate('/');
break;
case '2':
navigate('/calls/1');
case 'calls':
navigate('/calls');
break;
case '3':
navigate('/documents/1');
case 'documents':
navigate('/documents');
break;
case '4':
navigate('/appointments/1');
case 'appointments':
navigate('/appointments');
break;
case 'users':
navigate('/users');
break;
case 'translators':
navigate('/translators');
break;
case 'payments':
navigate('/payments');
break;
case 'billing-rules':
navigate('/billing/rules');
break;
case 'user-accounts':
navigate('/billing/accounts');
break;
case 'billing-stats':
navigate('/billing/stats');
break;
case 'settings':
navigate('/settings');
break;
default:
navigate('/');
}
};
const menuItems = [
{
key: 'dashboard',
icon: <DashboardOutlined />,
label: '仪表板',
},
{
key: 'calls',
icon: <PhoneOutlined />,
label: '通话记录',
},
{
key: 'documents',
icon: <FileTextOutlined />,
label: '文档翻译',
},
{
key: 'appointments',
icon: <CalendarOutlined />,
label: '预约管理',
},
{
key: 'users',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: 'translators',
icon: <TeamOutlined />,
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',
icon: <DollarOutlined />,
label: '支付记录',
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置',
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
breakpoint="lg"
collapsedWidth="0"
style={{
background: '#001529',
}}
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
theme="dark"
width={250}
>
<div style={{
height: 32,
margin: 16,
background: 'rgba(255,255,255,.2)',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '18px',
fontWeight: 'bold'
}}>
Twilio管理系统
{collapsed ? 'T' : 'Twilio管理后台'}
</div>
<Menu
theme="dark"
defaultSelectedKeys={['dashboard']}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
items={[
{
key: '1',
icon: <DashboardOutlined />,
label: '仪表板',
},
{
key: '2',
icon: <PhoneOutlined />,
label: '通话管理',
},
{
key: '3',
icon: <FileTextOutlined />,
label: '文档翻译',
},
{
key: '4',
icon: <CalendarOutlined />,
label: '预约管理',
},
]}
/>
</Sider>
<Layout>
<Header style={{
padding: 0,
background: '#fff',
boxShadow: '0 1px 4px rgba(0,21,41,.08)'
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{
padding: '0 24px',
fontSize: '18px',
fontWeight: 'bold'
}}>
Twilio翻译服务管理后台
</div>
<Title level={4} style={{ margin: 0 }}>
Twilio翻译服务管理系统
</Title>
</Header>
<Content style={{ margin: '24px 16px 0', overflow: 'initial' }}>
<div style={{
padding: 24,
background: '#fff',
minHeight: 360,
borderRadius: 8
}}>
<Content style={{
margin: '0',
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
<div style={{ padding: '24px' }}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<Dashboard />} />
<Route path="/calls" element={<CallList />} />
<Route path="/calls/:id" element={<CallDetail />} />
<Route path="/documents" element={<DocumentList />} />
<Route path="/documents/:id" element={<DocumentDetail />} />
<Route path="/appointments" element={<AppointmentList />} />
<Route path="/appointments/:id" element={<AppointmentDetail />} />
<Route path="/users" element={<UserList />} />
<Route path="/translators" element={<TranslatorList />} />
<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="*" element={<Dashboard />} />
</Routes>
</div>
</Content>
+51
View File
@@ -0,0 +1,51 @@
export interface TwilioConfig {
apiKey: string;
apiSecret: string;
accountSid: string;
videoServiceSid?: string;
conversationServiceSid?: string;
}
// Twilio配置
export const twilioConfig: TwilioConfig = {
apiKey: 'SK3b25e00e6914162a7cf829cffc415cb3',
apiSecret: 'PpGH298dlRgMSeGrexUjw1flczTVIw9H',
accountSid: 'AC_YOUR_ACCOUNT_SID', // 需要从Twilio控制台获取
videoServiceSid: '', // 可选:视频服务SID
conversationServiceSid: '', // 可选:对话服务SID
};
// Token服务器URL(开发环境)
export const TOKEN_SERVER_URL = process.env.NODE_ENV === 'production'
? 'https://your-production-server.com/api/twilio/token'
: 'http://localhost:3001/api/twilio/token';
// 视频配置选项
export const videoOptions = {
audio: true,
video: {
width: 640,
height: 480,
frameRate: 24,
},
bandwidthProfile: {
video: {
mode: 'collaboration' as const,
maxTracks: 10,
},
},
dominantSpeaker: true,
networkQuality: {
local: 1,
remote: 1,
},
};
// 房间类型
export enum RoomType {
GROUP = 'group',
GROUP_SMALL = 'group-small',
PEER_TO_PEER = 'peer-to-peer',
}
export default twilioConfig;
@@ -18,9 +18,7 @@ import {
Form,
Alert,
DatePicker,
TimePicker,
Rate,
Divider,
Statistic,
Row,
Col,
@@ -34,19 +32,14 @@ import {
VideoCameraOutlined,
DollarOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
TranslationOutlined,
StarOutlined,
MessageOutlined,
SettingOutlined,
TeamOutlined,
GlobalOutlined,
AuditOutlined,
FileTextOutlined,
EnvironmentOutlined,
SwapOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import moment from 'moment';
@@ -236,11 +229,11 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
try {
const refundAmount = values.amount || appointment.cost;
const updatedAppointment = {
const updatedAppointment: Appointment = {
...appointment,
refundAmount: refundAmount,
paymentStatus: 'refunded',
status: 'cancelled',
paymentStatus: 'refunded' as const,
status: 'cancelled' as const,
updatedAt: new Date().toISOString(),
};
@@ -326,8 +319,9 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
const getUrgencyText = (urgency: string) => {
const texts = {
normal: '普通',
low: '低',
high: '高',
urgent: '加急',
emergency: '特急',
};
return texts[urgency as keyof typeof texts] || urgency;
};
@@ -469,9 +463,9 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
<Card>
<Statistic
title="质量评分"
value={appointment.qualityScore}
value={appointment.qualityScore || 0}
suffix="/100"
valueStyle={{ color: appointment.qualityScore >= 90 ? '#3f8600' : '#faad14' }}
valueStyle={{ color: (appointment.qualityScore || 0) >= 90 ? '#3f8600' : '#faad14' }}
prefix={<AuditOutlined />}
/>
</Card>
@@ -498,7 +492,7 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
</Tag>
</Descriptions.Item>
<Descriptions.Item label="紧急程度" span={1}>
<Tag color={appointment.urgency === 'emergency' ? 'red' : appointment.urgency === 'urgent' ? 'orange' : 'default'}>
<Tag color={appointment.urgency === 'urgent' ? 'orange' : appointment.urgency === 'high' ? 'red' : 'default'}>
{getUrgencyText(appointment.urgency)}
</Tag>
</Descriptions.Item>
@@ -577,8 +571,8 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
<Descriptions.Item label="退款金额" span={1}>
<Space>
<DollarOutlined />
<Text type={appointment.refundAmount > 0 ? 'danger' : 'secondary'}>
¥{appointment.refundAmount.toFixed(2)}
<Text type={(appointment.refundAmount || 0) > 0 ? 'danger' : 'secondary'}>
¥{(appointment.refundAmount || 0).toFixed(2)}
</Text>
</Space>
</Descriptions.Item>
@@ -762,8 +756,8 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
<Descriptions column={2}>
<Descriptions.Item label="质量评分">
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: appointment.qualityScore >= 90 ? '#52c41a' : appointment.qualityScore >= 70 ? '#faad14' : '#ff4d4f' }}>
{appointment.qualityScore}/100
<div style={{ fontSize: '24px', fontWeight: 'bold', color: (appointment.qualityScore || 0) >= 90 ? '#52c41a' : (appointment.qualityScore || 0) >= 70 ? '#faad14' : '#ff4d4f' }}>
{appointment.qualityScore || 0}/100
</div>
<div style={{ color: '#999', fontSize: '12px' }}></div>
</div>
@@ -844,8 +838,9 @@ const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
>
<Select>
<Option value="normal"></Option>
<Option value="low"></Option>
<Option value="high"></Option>
<Option value="urgent"></Option>
<Option value="emergency"></Option>
</Select>
</Form.Item>
@@ -0,0 +1,737 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
DatePicker,
TimePicker,
Form,
Row,
Col,
Statistic,
Tooltip,
Avatar,
Badge,
Calendar
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
UserOutlined,
CalendarOutlined,
ClockCircleOutlined,
PhoneOutlined,
VideoCameraOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title } = Typography;
const { Option } = Select;
const { RangePicker } = DatePicker;
interface Appointment {
id: string;
clientName: string;
clientPhone: string;
clientEmail: string;
appointmentDate: string;
appointmentTime: string;
duration: number; // 分钟
serviceType: 'voice' | 'video' | 'document';
sourceLanguage: string;
targetLanguage: string;
translator?: string;
status: 'pending' | 'confirmed' | 'in-progress' | 'completed' | 'cancelled';
notes?: string;
cost: number;
createdTime: string;
}
const AppointmentList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [filteredAppointments, setFilteredAppointments] = useState<Appointment[]>([]);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [serviceTypeFilter, setServiceTypeFilter] = useState<string>('all');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const [calendarVisible, setCalendarVisible] = useState(false);
const [form] = Form.useForm();
// 模拟数据
const mockAppointments: Appointment[] = [
{
id: '1',
clientName: '张先生',
clientPhone: '13800138001',
clientEmail: 'zhang@example.com',
appointmentDate: '2024-01-16',
appointmentTime: '10:00',
duration: 60,
serviceType: 'video',
sourceLanguage: '中文',
targetLanguage: '英文',
translator: '王译员',
status: 'confirmed',
notes: '商务会议翻译',
cost: 300,
createdTime: '2024-01-15 14:30:00'
},
{
id: '2',
clientName: '李女士',
clientPhone: '13800138002',
clientEmail: 'li@example.com',
appointmentDate: '2024-01-16',
appointmentTime: '14:30',
duration: 90,
serviceType: 'voice',
sourceLanguage: '英文',
targetLanguage: '中文',
translator: '李译员',
status: 'in-progress',
notes: '医疗咨询翻译',
cost: 450,
createdTime: '2024-01-15 14:25:00'
},
{
id: '3',
clientName: '王总',
clientPhone: '13800138003',
clientEmail: 'wang@example.com',
appointmentDate: '2024-01-17',
appointmentTime: '09:00',
duration: 120,
serviceType: 'video',
sourceLanguage: '中文',
targetLanguage: '日文',
translator: '张译员',
status: 'pending',
notes: '技术交流会议',
cost: 600,
createdTime: '2024-01-15 14:20:00'
},
{
id: '4',
clientName: '陈先生',
clientPhone: '13800138004',
clientEmail: 'chen@example.com',
appointmentDate: '2024-01-15',
appointmentTime: '16:00',
duration: 45,
serviceType: 'document',
sourceLanguage: '德文',
targetLanguage: '中文',
translator: '赵译员',
status: 'completed',
notes: '合同翻译讨论',
cost: 225,
createdTime: '2024-01-15 14:15:00'
},
{
id: '5',
clientName: '刘女士',
clientPhone: '13800138005',
clientEmail: 'liu@example.com',
appointmentDate: '2024-01-18',
appointmentTime: '11:00',
duration: 30,
serviceType: 'voice',
sourceLanguage: '法文',
targetLanguage: '中文',
status: 'cancelled',
notes: '客户临时取消',
cost: 0,
createdTime: '2024-01-15 14:10:00'
}
];
const fetchAppointments = async () => {
setLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
setAppointments(mockAppointments);
setFilteredAppointments(mockAppointments);
message.success('预约列表加载成功');
} catch (error) {
message.error('加载预约列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAppointments();
}, []);
useEffect(() => {
let filtered = appointments;
// 搜索过滤
if (searchText) {
filtered = filtered.filter(apt =>
apt.clientName.toLowerCase().includes(searchText.toLowerCase()) ||
apt.clientPhone.includes(searchText) ||
apt.sourceLanguage.includes(searchText) ||
apt.targetLanguage.includes(searchText) ||
(apt.translator && apt.translator.includes(searchText))
);
}
// 状态过滤
if (statusFilter !== 'all') {
filtered = filtered.filter(apt => apt.status === statusFilter);
}
// 服务类型过滤
if (serviceTypeFilter !== 'all') {
filtered = filtered.filter(apt => apt.serviceType === serviceTypeFilter);
}
// 日期范围过滤
if (dateRange) {
const [startDate, endDate] = dateRange;
filtered = filtered.filter(apt => {
const aptDate = dayjs(apt.appointmentDate);
return aptDate.isAfter(startDate.subtract(1, 'day')) &&
aptDate.isBefore(endDate.add(1, 'day'));
});
}
setFilteredAppointments(filtered);
}, [appointments, searchText, statusFilter, serviceTypeFilter, dateRange]);
const getStatusTag = (status: string) => {
const statusConfig = {
pending: { color: 'orange', text: '待确认' },
confirmed: { color: 'blue', text: '已确认' },
'in-progress': { color: 'green', text: '进行中' },
completed: { color: 'cyan', text: '已完成' },
cancelled: { color: 'red', text: '已取消' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const getServiceTypeTag = (type: string) => {
const typeConfig = {
voice: { color: 'blue', text: '语音翻译', icon: <PhoneOutlined /> },
video: { color: 'green', text: '视频翻译', icon: <VideoCameraOutlined /> },
document: { color: 'purple', text: '文档讨论', icon: <EyeOutlined /> }
};
const config = typeConfig[type as keyof typeof typeConfig];
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
const handleStatusChange = (appointmentId: string, newStatus: string) => {
const updatedAppointments = appointments.map(apt =>
apt.id === appointmentId ? { ...apt, status: newStatus as Appointment['status'] } : apt
);
setAppointments(updatedAppointments);
message.success('状态更新成功');
};
const handleEdit = (appointment: Appointment) => {
setEditingAppointment(appointment);
form.setFieldsValue({
...appointment,
appointmentDate: dayjs(appointment.appointmentDate),
appointmentTime: dayjs(appointment.appointmentTime, 'HH:mm')
});
setModalVisible(true);
};
const handleDelete = (appointment: Appointment) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${appointment.clientName} 的预约吗?`,
onOk: () => {
const newAppointments = appointments.filter(apt => apt.id !== appointment.id);
setAppointments(newAppointments);
message.success('预约删除成功');
}
});
};
const handleSave = async (values: any) => {
try {
const appointmentData = {
...values,
appointmentDate: values.appointmentDate.format('YYYY-MM-DD'),
appointmentTime: values.appointmentTime.format('HH:mm'),
};
if (editingAppointment) {
// 更新预约
const updatedAppointments = appointments.map(apt =>
apt.id === editingAppointment.id ? { ...apt, ...appointmentData } : apt
);
setAppointments(updatedAppointments);
message.success('预约更新成功');
} else {
// 新增预约
const newAppointment: Appointment = {
id: Date.now().toString(),
...appointmentData,
status: 'pending',
createdTime: new Date().toLocaleString()
};
setAppointments([...appointments, newAppointment]);
message.success('预约创建成功');
}
setModalVisible(false);
setEditingAppointment(null);
form.resetFields();
} catch (error) {
message.error('保存失败');
}
};
const columns: ColumnsType<Appointment> = [
{
title: '客户信息',
key: 'client',
width: 200,
render: (_, record) => (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<Avatar size="small" icon={<UserOutlined />} />
<span style={{ marginLeft: 8, fontWeight: 'bold' }}>{record.clientName}</span>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{record.clientPhone}
</div>
</div>
)
},
{
title: '预约时间',
key: 'datetime',
width: 150,
render: (_, record) => (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{record.appointmentDate}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{record.appointmentTime} ({record.duration})
</div>
</div>
)
},
{
title: '服务类型',
dataIndex: 'serviceType',
key: 'serviceType',
width: 120,
render: getServiceTypeTag
},
{
title: '语言对',
key: 'languages',
width: 150,
render: (_, record) => (
<div>
<Tag color="blue">{record.sourceLanguage}</Tag>
<span style={{ margin: '0 4px' }}></span>
<Tag color="green">{record.targetLanguage}</Tag>
</div>
)
},
{
title: '译员',
dataIndex: 'translator',
key: 'translator',
width: 100,
render: (text) => text || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: getStatusTag
},
{
title: '费用(元)',
dataIndex: 'cost',
key: 'cost',
width: 100,
render: (cost) => cost > 0 ? `¥${cost.toFixed(2)}` : '-'
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
{record.status === 'pending' && (
<Tooltip title="确认">
<Button
size="small"
icon={<CheckOutlined />}
style={{ color: 'green' }}
onClick={() => handleStatusChange(record.id, 'confirmed')}
/>
</Tooltip>
)}
{record.status !== 'cancelled' && record.status !== 'completed' && (
<Tooltip title="取消">
<Button
size="small"
icon={<CloseOutlined />}
danger
onClick={() => handleStatusChange(record.id, 'cancelled')}
/>
</Tooltip>
)}
<Tooltip title="删除">
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
</Tooltip>
</Space>
),
},
];
// 统计数据
const stats = {
total: filteredAppointments.length,
pending: filteredAppointments.filter(a => a.status === 'pending').length,
confirmed: filteredAppointments.filter(a => a.status === 'confirmed').length,
inProgress: filteredAppointments.filter(a => a.status === 'in-progress').length,
completed: filteredAppointments.filter(a => a.status === 'completed').length,
totalRevenue: filteredAppointments.filter(a => a.status === 'completed').reduce((sum, a) => sum + a.cost, 0)
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={4}>
<Card>
<Statistic
title="总预约数"
value={stats.total}
prefix={<CalendarOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="待确认"
value={stats.pending}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="已确认"
value={stats.confirmed}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="进行中"
value={stats.inProgress}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="已完成"
value={stats.completed}
valueStyle={{ color: '#13c2c2' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="总收入"
value={stats.totalRevenue}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<Card>
{/* 搜索和筛选 */}
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={6}>
<Input
placeholder="搜索客户、电话、语言..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={4}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="pending"></Option>
<Option value="confirmed"></Option>
<Option value="in-progress"></Option>
<Option value="completed"></Option>
<Option value="cancelled"></Option>
</Select>
</Col>
<Col span={4}>
<Select
value={serviceTypeFilter}
onChange={setServiceTypeFilter}
style={{ width: '100%' }}
placeholder="服务类型"
>
<Option value="all"></Option>
<Option value="voice"></Option>
<Option value="video"></Option>
<Option value="document"></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
style={{ width: '100%' }}
value={dateRange}
onChange={(dates) => setDateRange(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col span={4}>
<Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingAppointment(null);
form.resetFields();
setModalVisible(true);
}}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchAppointments}
loading={loading}
/>
</Space>
</Col>
</Row>
{/* 预约列表表格 */}
<Table
columns={columns}
dataSource={filteredAppointments}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredAppointments.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
{/* 预约编辑弹窗 */}
<Modal
title={editingAppointment ? '编辑预约' : '新增预约'}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
setEditingAppointment(null);
form.resetFields();
}}
onOk={() => form.submit()}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="clientName"
label="客户姓名"
rules={[{ required: true, message: '请输入客户姓名' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="clientPhone"
label="联系电话"
rules={[{ required: true, message: '请输入联系电话' }]}
>
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="appointmentDate"
label="预约日期"
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="appointmentTime"
label="预约时间"
rules={[{ required: true, message: '请选择预约时间' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="duration"
label="时长(分钟)"
rules={[{ required: true, message: '请输入时长' }]}
>
<Input type="number" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="serviceType"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
>
<Select>
<Option value="voice"></Option>
<Option value="video"></Option>
<Option value="document"></Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="cost"
label="费用(元)"
rules={[{ required: true, message: '请输入费用' }]}
>
<Input type="number" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="sourceLanguage"
label="源语言"
rules={[{ required: true, message: '请选择源语言' }]}
>
<Select>
<Option value="中文"></Option>
<Option value="英文"></Option>
<Option value="日文"></Option>
<Option value="韩文"></Option>
<Option value="法文"></Option>
<Option value="德文"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="targetLanguage"
label="目标语言"
rules={[{ required: true, message: '请选择目标语言' }]}
>
<Select>
<Option value="中文"></Option>
<Option value="英文"></Option>
<Option value="日文"></Option>
<Option value="韩文"></Option>
<Option value="法文"></Option>
<Option value="德文"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="notes"
label="备注"
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AppointmentList;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,3 @@
export { default as BillingRules } from './BillingRules';
export { default as UserAccounts } from './UserAccounts';
export { default as BillingStats } from './BillingStats';
@@ -0,0 +1,470 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
DatePicker,
Row,
Col,
Statistic
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
ReloadOutlined,
PhoneOutlined,
ClockCircleOutlined,
UserOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
interface CallRecord {
id: string;
caller: string;
callee: string;
startTime: string;
endTime: string;
duration: string;
status: 'completed' | 'ongoing' | 'failed' | 'missed';
type: 'voice' | 'video';
language: string;
translator?: string;
quality: number;
cost: number;
}
const CallList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [calls, setCalls] = useState<CallRecord[]>([]);
const [filteredCalls, setFilteredCalls] = useState<CallRecord[]>([]);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [selectedCall, setSelectedCall] = useState<CallRecord | null>(null);
const [detailModalVisible, setDetailModalVisible] = useState(false);
// 模拟数据
const mockCalls: CallRecord[] = [
{
id: '1',
caller: '张三 (+86 138****1234)',
callee: '李四 (+1 555****5678)',
startTime: '2024-01-15 14:30:00',
endTime: '2024-01-15 14:45:30',
duration: '15:30',
status: 'completed',
type: 'video',
language: '中文-英文',
translator: '王译员',
quality: 4.8,
cost: 45.50
},
{
id: '2',
caller: '李四 (+1 555****5678)',
callee: '王五 (+86 139****5678)',
startTime: '2024-01-15 14:25:00',
endTime: '',
duration: '08:45',
status: 'ongoing',
type: 'voice',
language: '英文-中文',
translator: '赵译员',
quality: 0,
cost: 0
},
{
id: '3',
caller: '王五 (+86 139****5678)',
callee: '赵六 (+81 90****1234)',
startTime: '2024-01-15 14:20:00',
endTime: '2024-01-15 14:42:10',
duration: '22:10',
status: 'completed',
type: 'video',
language: '中文-日文',
translator: '孙译员',
quality: 4.9,
cost: 66.30
},
{
id: '4',
caller: '赵六 (+81 90****1234)',
callee: '孙七 (+86 137****9876)',
startTime: '2024-01-15 14:15:00',
endTime: '2024-01-15 14:20:15',
duration: '05:15',
status: 'failed',
type: 'voice',
language: '日文-中文',
translator: '',
quality: 0,
cost: 0
},
{
id: '5',
caller: '孙七 (+86 137****9876)',
callee: '周八 (+49 30****5678)',
startTime: '2024-01-15 14:10:00',
endTime: '',
duration: '00:00',
status: 'missed',
type: 'voice',
language: '中文-德文',
translator: '',
quality: 0,
cost: 0
}
];
const fetchCalls = async () => {
setLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
setCalls(mockCalls);
setFilteredCalls(mockCalls);
message.success('通话记录加载成功');
} catch (error) {
message.error('加载通话记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCalls();
}, []);
useEffect(() => {
let filtered = calls;
// 搜索过滤
if (searchText) {
filtered = filtered.filter(call =>
call.caller.toLowerCase().includes(searchText.toLowerCase()) ||
call.callee.toLowerCase().includes(searchText.toLowerCase()) ||
call.language.includes(searchText) ||
(call.translator && call.translator.includes(searchText))
);
}
// 状态过滤
if (statusFilter !== 'all') {
filtered = filtered.filter(call => call.status === statusFilter);
}
// 类型过滤
if (typeFilter !== 'all') {
filtered = filtered.filter(call => call.type === typeFilter);
}
setFilteredCalls(filtered);
}, [calls, searchText, statusFilter, typeFilter]);
const getStatusTag = (status: string) => {
const statusConfig = {
completed: { color: 'green', text: '已完成' },
ongoing: { color: 'blue', text: '进行中' },
failed: { color: 'red', text: '失败' },
missed: { color: 'orange', text: '未接听' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const getTypeTag = (type: string) => {
return type === 'video' ?
<Tag color="purple"></Tag> :
<Tag color="cyan"></Tag>;
};
const columns: ColumnsType<CallRecord> = [
{
title: '通话ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '呼叫方',
dataIndex: 'caller',
key: 'caller',
width: 200,
render: (text) => (
<div>
<UserOutlined style={{ marginRight: 8 }} />
{text}
</div>
)
},
{
title: '接听方',
dataIndex: 'callee',
key: 'callee',
width: 200,
render: (text) => (
<div>
<UserOutlined style={{ marginRight: 8 }} />
{text}
</div>
)
},
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
},
{
title: '通话时长',
dataIndex: 'duration',
key: 'duration',
width: 100,
render: (text) => (
<div>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{text}
</div>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: getStatusTag
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: getTypeTag
},
{
title: '语言',
dataIndex: 'language',
key: 'language',
width: 120,
},
{
title: '译员',
dataIndex: 'translator',
key: 'translator',
width: 100,
render: (text) => text || '-'
},
{
title: '评分',
dataIndex: 'quality',
key: 'quality',
width: 80,
render: (score) => score > 0 ? `${score}/5` : '-'
},
{
title: '费用(元)',
dataIndex: 'cost',
key: 'cost',
width: 100,
render: (cost) => cost > 0 ? `¥${cost.toFixed(2)}` : '-'
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setSelectedCall(record);
setDetailModalVisible(true);
}}
>
</Button>
),
},
];
// 统计数据
const stats = {
total: filteredCalls.length,
completed: filteredCalls.filter(c => c.status === 'completed').length,
ongoing: filteredCalls.filter(c => c.status === 'ongoing').length,
failed: filteredCalls.filter(c => c.status === 'failed').length,
totalRevenue: filteredCalls.reduce((sum, c) => sum + c.cost, 0)
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={6}>
<Card>
<Statistic
title="总通话数"
value={stats.total}
prefix={<PhoneOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已完成"
value={stats.completed}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="进行中"
value={stats.ongoing}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总收入"
value={stats.totalRevenue}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<Card>
{/* 搜索和筛选 */}
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={6}>
<Input
placeholder="搜索呼叫方、接听方、译员..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={4}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="completed"></Option>
<Option value="ongoing"></Option>
<Option value="failed"></Option>
<Option value="missed"></Option>
</Select>
</Col>
<Col span={4}>
<Select
value={typeFilter}
onChange={setTypeFilter}
style={{ width: '100%' }}
placeholder="类型筛选"
>
<Option value="all"></Option>
<Option value="voice"></Option>
<Option value="video"></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker style={{ width: '100%' }} />
</Col>
<Col span={4}>
<Space>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={fetchCalls}
loading={loading}
>
</Button>
</Space>
</Col>
</Row>
{/* 通话记录表格 */}
<Table
columns={columns}
dataSource={filteredCalls}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredCalls.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
{/* 详情弹窗 */}
<Modal
title="通话详情"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
</Button>
]}
width={600}
>
{selectedCall && (
<div>
<Row gutter={16}>
<Col span={12}>
<p><strong>ID:</strong> {selectedCall.id}</p>
<p><strong>:</strong> {selectedCall.caller}</p>
<p><strong>:</strong> {selectedCall.callee}</p>
<p><strong>:</strong> {selectedCall.startTime}</p>
<p><strong>:</strong> {selectedCall.endTime || '进行中'}</p>
<p><strong>:</strong> {selectedCall.duration}</p>
</Col>
<Col span={12}>
<p><strong>:</strong> {getStatusTag(selectedCall.status)}</p>
<p><strong>:</strong> {getTypeTag(selectedCall.type)}</p>
<p><strong>:</strong> {selectedCall.language}</p>
<p><strong>:</strong> {selectedCall.translator || '无'}</p>
<p><strong>:</strong> {selectedCall.quality > 0 ? `${selectedCall.quality}/5` : '未评分'}</p>
<p><strong>:</strong> {selectedCall.cost > 0 ? `¥${selectedCall.cost.toFixed(2)}` : '免费'}</p>
</Col>
</Row>
</div>
)}
</Modal>
</div>
);
};
export default CallList;
+297 -22
View File
@@ -1,55 +1,231 @@
import React from 'react';
import { Card, Row, Col, Statistic, Typography } from 'antd';
import React, { useState, useEffect } from 'react';
import {
Card,
Row,
Col,
Statistic,
Typography,
Table,
Tag,
Progress,
Spin,
message,
Space,
Button
} from 'antd';
import {
PhoneOutlined,
FileTextOutlined,
CalendarOutlined,
DollarOutlined
DollarOutlined,
UserOutlined,
VideoCameraOutlined,
ReloadOutlined,
TrophyOutlined
} from '@ant-design/icons';
const { Title } = Typography;
const { Title, Text } = Typography;
interface DashboardData {
totalCalls: number;
totalDocuments: number;
totalAppointments: number;
totalRevenue: number;
activeUsers: number;
videoCalls: number;
recentCalls: Array<{
id: string;
caller: string;
duration: string;
status: 'completed' | 'ongoing' | 'failed';
time: string;
}>;
systemStatus: {
api: 'online' | 'offline';
database: 'online' | 'offline';
twilio: 'online' | 'offline';
};
}
const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<DashboardData>({
totalCalls: 0,
totalDocuments: 0,
totalAppointments: 0,
totalRevenue: 0,
activeUsers: 0,
videoCalls: 0,
recentCalls: [],
systemStatus: {
api: 'online',
database: 'online',
twilio: 'online'
}
});
const fetchDashboardData = async () => {
try {
setLoading(true);
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
const mockData: DashboardData = {
totalCalls: 1128,
totalDocuments: 892,
totalAppointments: 456,
totalRevenue: 25680,
activeUsers: 89,
videoCalls: 234,
recentCalls: [
{
id: '1',
caller: '张三',
duration: '15:30',
status: 'completed',
time: '2024-01-15 14:30'
},
{
id: '2',
caller: '李四',
duration: '08:45',
status: 'ongoing',
time: '2024-01-15 14:25'
},
{
id: '3',
caller: '王五',
duration: '22:10',
status: 'completed',
time: '2024-01-15 14:20'
},
{
id: '4',
caller: '赵六',
duration: '05:15',
status: 'failed',
time: '2024-01-15 14:15'
}
],
systemStatus: {
api: 'online',
database: 'online',
twilio: 'online'
}
};
setData(mockData);
message.success('仪表板数据加载成功');
} catch (error) {
console.error('获取仪表板数据失败:', error);
message.error('获取仪表板数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDashboardData();
}, []);
const recentCallsColumns = [
{
title: '呼叫者',
dataIndex: 'caller',
key: 'caller',
},
{
title: '通话时长',
dataIndex: 'duration',
key: 'duration',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const statusConfig = {
completed: { color: 'green', text: '已完成' },
ongoing: { color: 'blue', text: '进行中' },
failed: { color: 'red', text: '失败' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color}>{config.text}</Tag>;
}
},
{
title: '时间',
dataIndex: 'time',
key: 'time',
}
];
if (loading) {
return (
<div style={{
padding: '24px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px'
}}>
<Spin size="large" tip="加载仪表板数据中..." />
</div>
);
}
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<Title level={2} style={{ margin: 0 }}></Title>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={fetchDashboardData}
loading={loading}
>
</Button>
</div>
<Row gutter={16}>
<Col span={6}>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总通话数"
value={1128}
value={data.totalCalls}
prefix={<PhoneOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="文档翻译"
value={892}
value={data.totalDocuments}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="预约服务"
value={456}
value={data.totalAppointments}
prefix={<CalendarOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col span={6}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总收入"
value={25680}
value={data.totalRevenue}
prefix={<DollarOutlined />}
valueStyle={{ color: '#cf1322' }}
suffix="元"
@@ -57,14 +233,113 @@ const Dashboard: React.FC = () => {
</Card>
</Col>
</Row>
<div style={{ marginTop: '24px' }}>
<Card title="系统状态">
<p> </p>
<p> 线</p>
<p> </p>
</Card>
</div>
{/* 第二行统计 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="活跃用户"
value={data.activeUsers}
prefix={<UserOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="视频通话"
value={data.videoCalls}
prefix={<VideoCameraOutlined />}
valueStyle={{ color: '#eb2f96' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="成功率"
value={94.5}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#52c41a' }}
suffix="%"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<div style={{ textAlign: 'center' }}>
<Text type="secondary"></Text>
<Progress
type="circle"
percent={75}
size={80}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
</div>
</Card>
</Col>
</Row>
<Row gutter={16}>
{/* 最近通话记录 */}
<Col xs={24} lg={16}>
<Card title="最近通话记录" style={{ marginBottom: '24px' }}>
<Table
columns={recentCallsColumns}
dataSource={data.recentCalls}
pagination={false}
size="small"
rowKey="id"
/>
</Card>
</Col>
{/* 系统状态 */}
<Col xs={24} lg={8}>
<Card title="系统状态" style={{ marginBottom: '24px' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text>API服务</Text>
<Tag color={data.systemStatus.api === 'online' ? 'green' : 'red'}>
{data.systemStatus.api === 'online' ? '在线' : '离线'}
</Tag>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text></Text>
<Tag color={data.systemStatus.database === 'online' ? 'green' : 'red'}>
{data.systemStatus.database === 'online' ? '在线' : '离线'}
</Tag>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text>Twilio服务</Text>
<Tag color={data.systemStatus.twilio === 'online' ? 'green' : 'red'}>
{data.systemStatus.twilio === 'online' ? '在线' : '离线'}
</Tag>
</div>
</Space>
</Card>
{/* 快速操作 */}
<Card title="快速操作">
<Space direction="vertical" style={{ width: '100%' }}>
<Button type="primary" block icon={<PhoneOutlined />}>
</Button>
<Button block icon={<FileTextOutlined />}>
</Button>
<Button block icon={<CalendarOutlined />}>
</Button>
</Space>
</Card>
</Col>
</Row>
</div>
);
};
@@ -0,0 +1,404 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
Upload,
Progress,
Row,
Col,
Statistic,
Tooltip
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
DownloadOutlined,
UploadOutlined,
ReloadOutlined,
FileTextOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
DeleteOutlined,
TranslationOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { UploadProps } from 'antd';
const { Title } = Typography;
const { Option } = Select;
interface Document {
id: string;
fileName: string;
fileType: string;
fileSize: number;
uploadTime: string;
status: 'pending' | 'translating' | 'completed' | 'failed';
sourceLanguage: string;
targetLanguage: string;
translator?: string;
progress: number;
downloadCount: number;
cost: number;
}
const DocumentList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [documents, setDocuments] = useState<Document[]>([]);
const [filteredDocuments, setFilteredDocuments] = useState<Document[]>([]);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [uploadModalVisible, setUploadModalVisible] = useState(false);
// 模拟数据
const mockDocuments: Document[] = [
{
id: '1',
fileName: '商业合同.pdf',
fileType: 'pdf',
fileSize: 2048576,
uploadTime: '2024-01-15 14:30:00',
status: 'completed',
sourceLanguage: '中文',
targetLanguage: '英文',
translator: '王译员',
progress: 100,
downloadCount: 5,
cost: 128.50
},
{
id: '2',
fileName: '技术文档.docx',
fileType: 'docx',
fileSize: 1536000,
uploadTime: '2024-01-15 14:25:00',
status: 'translating',
sourceLanguage: '英文',
targetLanguage: '中文',
translator: '李译员',
progress: 65,
downloadCount: 0,
cost: 0
},
{
id: '3',
fileName: '财务报表.xlsx',
fileType: 'xlsx',
fileSize: 512000,
uploadTime: '2024-01-15 14:20:00',
status: 'completed',
sourceLanguage: '中文',
targetLanguage: '日文',
translator: '张译员',
progress: 100,
downloadCount: 12,
cost: 85.30
}
];
const fetchDocuments = async () => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setDocuments(mockDocuments);
setFilteredDocuments(mockDocuments);
message.success('文档列表加载成功');
} catch (error) {
message.error('加载文档列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDocuments();
}, []);
useEffect(() => {
let filtered = documents;
if (searchText) {
filtered = filtered.filter(doc =>
doc.fileName.toLowerCase().includes(searchText.toLowerCase()) ||
doc.sourceLanguage.includes(searchText) ||
doc.targetLanguage.includes(searchText) ||
(doc.translator && doc.translator.includes(searchText))
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(doc => doc.status === statusFilter);
}
setFilteredDocuments(filtered);
}, [documents, searchText, statusFilter]);
const getStatusTag = (status: string) => {
const statusConfig = {
pending: { color: 'orange', text: '待处理' },
translating: { color: 'blue', text: '翻译中' },
completed: { color: 'green', text: '已完成' },
failed: { color: 'red', text: '失败' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const getFileIcon = (fileType: string) => {
const iconMap = {
pdf: <FilePdfOutlined style={{ color: '#ff4d4f' }} />,
docx: <FileWordOutlined style={{ color: '#1890ff' }} />,
xlsx: <FileExcelOutlined style={{ color: '#52c41a' }} />,
txt: <FileTextOutlined style={{ color: '#722ed1' }} />
};
return iconMap[fileType as keyof typeof iconMap] || <FileTextOutlined />;
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleDownload = (document: Document) => {
if (document.status !== 'completed') {
message.warning('文档尚未翻译完成,无法下载');
return;
}
message.success(`开始下载:${document.fileName}`);
};
const handleDelete = (document: Document) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除文档 "${document.fileName}" 吗?`,
onOk: () => {
const newDocuments = documents.filter(doc => doc.id !== document.id);
setDocuments(newDocuments);
message.success('文档删除成功');
}
});
};
const columns: ColumnsType<Document> = [
{
title: '文件名',
dataIndex: 'fileName',
key: 'fileName',
width: 250,
render: (text, record) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
{getFileIcon(record.fileType)}
<span style={{ marginLeft: 8 }}>{text}</span>
</div>
)
},
{
title: '文件大小',
dataIndex: 'fileSize',
key: 'fileSize',
width: 120,
render: formatFileSize
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: getStatusTag
},
{
title: '翻译进度',
dataIndex: 'progress',
key: 'progress',
width: 120,
render: (progress, record) => (
<Progress
percent={progress}
size="small"
status={record.status === 'failed' ? 'exception' : undefined}
/>
)
},
{
title: '源语言',
dataIndex: 'sourceLanguage',
key: 'sourceLanguage',
width: 100,
},
{
title: '目标语言',
dataIndex: 'targetLanguage',
key: 'targetLanguage',
width: 100,
},
{
title: '费用(元)',
dataIndex: 'cost',
key: 'cost',
width: 100,
render: (cost) => cost > 0 ? `¥${cost.toFixed(2)}` : '-'
},
{
title: '操作',
key: 'action',
width: 160,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
/>
</Tooltip>
<Tooltip title="下载">
<Button
size="small"
icon={<DownloadOutlined />}
disabled={record.status !== 'completed'}
onClick={() => handleDownload(record)}
/>
</Tooltip>
<Tooltip title="删除">
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
</Tooltip>
</Space>
),
},
];
const stats = {
total: filteredDocuments.length,
completed: filteredDocuments.filter(d => d.status === 'completed').length,
translating: filteredDocuments.filter(d => d.status === 'translating').length,
totalRevenue: filteredDocuments.reduce((sum, d) => sum + d.cost, 0)
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={6}>
<Card>
<Statistic
title="总文档数"
value={stats.total}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="已完成"
value={stats.completed}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="翻译中"
value={stats.translating}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总收入"
value={stats.totalRevenue}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<Card>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={8}>
<Input
placeholder="搜索文件名、语言、译员..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={6}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="pending"></Option>
<Option value="translating"></Option>
<Option value="completed"></Option>
<Option value="failed"></Option>
</Select>
</Col>
<Col span={10}>
<Space>
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setUploadModalVisible(true)}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchDocuments}
loading={loading}
>
</Button>
</Space>
</Col>
</Row>
<Table
columns={columns}
dataSource={filteredDocuments}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredDocuments.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
</div>
);
};
export default DocumentList;
@@ -0,0 +1,525 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
Row,
Col,
Statistic,
Tooltip,
DatePicker,
Descriptions,
Divider
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
ReloadOutlined,
DollarOutlined,
CreditCardOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
DownloadOutlined,
UndoOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title } = Typography;
const { Option } = Select;
const { RangePicker } = DatePicker;
interface Payment {
id: string;
orderId: string;
userId: string;
userName: string;
amount: number;
paymentMethod: 'credit_card' | 'alipay' | 'wechat' | 'paypal';
status: 'pending' | 'completed' | 'failed' | 'refunded' | 'cancelled';
transactionId: string;
serviceType: 'voice_call' | 'video_call' | 'document_translation' | 'appointment';
serviceName: string;
createdAt: string;
completedAt?: string;
refundAmount?: number;
refundReason?: string;
currency: string;
fee: number; // 手续费
}
const PaymentList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [payments, setPayments] = useState<Payment[]>([]);
const [filteredPayments, setFilteredPayments] = useState<Payment[]>([]);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [methodFilter, setMethodFilter] = useState<string>('all');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [selectedPayment, setSelectedPayment] = useState<Payment | null>(null);
const [detailModalVisible, setDetailModalVisible] = useState(false);
// 模拟数据
const mockPayments: Payment[] = [
{
id: '1',
orderId: 'ORD-2024-001',
userId: 'U001',
userName: '张三',
amount: 150.00,
paymentMethod: 'alipay',
status: 'completed',
transactionId: 'TXN-20240115-001',
serviceType: 'voice_call',
serviceName: '中英文语音翻译',
createdAt: '2024-01-15 14:30:00',
completedAt: '2024-01-15 14:31:00',
currency: 'CNY',
fee: 4.50
},
{
id: '2',
orderId: 'ORD-2024-002',
userId: 'U002',
userName: '李四',
amount: 280.00,
paymentMethod: 'wechat',
status: 'completed',
transactionId: 'TXN-20240115-002',
serviceType: 'document_translation',
serviceName: '商务文档翻译',
createdAt: '2024-01-15 13:45:00',
completedAt: '2024-01-15 13:46:00',
currency: 'CNY',
fee: 8.40
},
{
id: '3',
orderId: 'ORD-2024-003',
userId: 'U003',
userName: '王五',
amount: 320.00,
paymentMethod: 'credit_card',
status: 'refunded',
transactionId: 'TXN-20240115-003',
serviceType: 'video_call',
serviceName: '视频会议翻译',
createdAt: '2024-01-15 12:20:00',
completedAt: '2024-01-15 12:21:00',
refundAmount: 320.00,
refundReason: '服务质量问题',
currency: 'CNY',
fee: 9.60
},
{
id: '4',
orderId: 'ORD-2024-004',
userId: 'U004',
userName: '赵六',
amount: 450.00,
paymentMethod: 'paypal',
status: 'pending',
transactionId: 'TXN-20240115-004',
serviceType: 'appointment',
serviceName: '专业咨询预约',
createdAt: '2024-01-15 16:10:00',
currency: 'USD',
fee: 13.50
},
{
id: '5',
orderId: 'ORD-2024-005',
userId: 'U005',
userName: '孙七',
amount: 180.00,
paymentMethod: 'alipay',
status: 'failed',
transactionId: 'TXN-20240115-005',
serviceType: 'voice_call',
serviceName: '法语口译服务',
createdAt: '2024-01-15 11:30:00',
currency: 'CNY',
fee: 5.40
}
];
const fetchPayments = async () => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setPayments(mockPayments);
setFilteredPayments(mockPayments);
message.success('支付记录加载成功');
} catch (error) {
message.error('加载支付记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPayments();
}, []);
useEffect(() => {
let filtered = payments;
if (searchText) {
filtered = filtered.filter(payment =>
payment.orderId.toLowerCase().includes(searchText.toLowerCase()) ||
payment.userName.toLowerCase().includes(searchText.toLowerCase()) ||
payment.transactionId.toLowerCase().includes(searchText.toLowerCase()) ||
payment.serviceName.includes(searchText)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(payment => payment.status === statusFilter);
}
if (methodFilter !== 'all') {
filtered = filtered.filter(payment => payment.paymentMethod === methodFilter);
}
if (dateRange) {
const [start, end] = dateRange;
filtered = filtered.filter(payment => {
const paymentDate = dayjs(payment.createdAt);
return paymentDate.isAfter(start.startOf('day')) && paymentDate.isBefore(end.endOf('day'));
});
}
setFilteredPayments(filtered);
}, [payments, searchText, statusFilter, methodFilter, dateRange]);
const getStatusTag = (status: string) => {
const statusConfig = {
pending: { color: 'orange', text: '待支付', icon: <ExclamationCircleOutlined /> },
completed: { color: 'green', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'red', text: '支付失败', icon: <CloseCircleOutlined /> },
refunded: { color: 'purple', text: '已退款', icon: <UndoOutlined /> },
cancelled: { color: 'gray', text: '已取消', icon: <CloseCircleOutlined /> }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
};
const getPaymentMethodTag = (method: string) => {
const methodConfig = {
credit_card: { color: 'blue', text: '信用卡' },
alipay: { color: 'green', text: '支付宝' },
wechat: { color: 'lime', text: '微信支付' },
paypal: { color: 'gold', text: 'PayPal' }
};
const config = methodConfig[method as keyof typeof methodConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleViewDetail = (payment: Payment) => {
setSelectedPayment(payment);
setDetailModalVisible(true);
};
const columns: ColumnsType<Payment> = [
{
title: '订单号',
dataIndex: 'orderId',
key: 'orderId',
width: 140,
render: (orderId) => (
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>
{orderId}
</span>
)
},
{
title: '用户',
dataIndex: 'userName',
key: 'userName',
width: 100
},
{
title: '金额',
key: 'amount',
width: 120,
render: (_, record) => (
<div>
<div style={{ fontWeight: 'bold' }}>
{record.currency} {record.amount.toFixed(2)}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
: {record.currency} {record.fee.toFixed(2)}
</div>
</div>
)
},
{
title: '支付方式',
dataIndex: 'paymentMethod',
key: 'paymentMethod',
width: 120,
render: getPaymentMethodTag
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: getStatusTag
},
{
title: '服务类型',
dataIndex: 'serviceName',
key: 'serviceName',
width: 150,
ellipsis: true
},
{
title: '交易时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (time) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
/>
</Tooltip>
<Tooltip title="下载凭证">
<Button
size="small"
icon={<DownloadOutlined />}
disabled={record.status !== 'completed'}
/>
</Tooltip>
</Space>
),
},
];
const stats = {
total: filteredPayments.length,
completed: filteredPayments.filter(p => p.status === 'completed').length,
pending: filteredPayments.filter(p => p.status === 'pending').length,
failed: filteredPayments.filter(p => p.status === 'failed').length,
refunded: filteredPayments.filter(p => p.status === 'refunded').length,
totalAmount: filteredPayments
.filter(p => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0),
totalFee: filteredPayments
.filter(p => p.status === 'completed')
.reduce((sum, p) => sum + p.fee, 0),
refundAmount: filteredPayments
.filter(p => p.status === 'refunded')
.reduce((sum, p) => sum + (p.refundAmount || 0), 0)
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={6}>
<Card>
<Statistic
title="总交易数"
value={stats.total}
prefix={<CreditCardOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="成功交易"
value={stats.completed}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="交易总额"
value={stats.totalAmount}
precision={2}
prefix={<DollarOutlined />}
valueStyle={{ color: '#fa8c16' }}
suffix="CNY"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="手续费收入"
value={stats.totalFee}
precision={2}
valueStyle={{ color: '#722ed1' }}
suffix="CNY"
/>
</Card>
</Col>
</Row>
<Card>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={6}>
<Input
placeholder="搜索订单号、用户、交易号..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={4}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="pending"></Option>
<Option value="completed"></Option>
<Option value="failed"></Option>
<Option value="refunded">退</Option>
<Option value="cancelled"></Option>
</Select>
</Col>
<Col span={4}>
<Select
value={methodFilter}
onChange={setMethodFilter}
style={{ width: '100%' }}
placeholder="支付方式"
>
<Option value="all"></Option>
<Option value="credit_card"></Option>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
<Option value="paypal">PayPal</Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
style={{ width: '100%' }}
value={dateRange}
onChange={(dates) => setDateRange(dates as [dayjs.Dayjs, dayjs.Dayjs] | null)}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col span={4}>
<Button
icon={<ReloadOutlined />}
onClick={fetchPayments}
loading={loading}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={filteredPayments}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredPayments.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
<Modal
title="支付详情"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
</Button>
]}
width={600}
>
{selectedPayment && (
<Descriptions column={2} bordered>
<Descriptions.Item label="订单号" span={2}>
{selectedPayment.orderId}
</Descriptions.Item>
<Descriptions.Item label="交易号" span={2}>
{selectedPayment.transactionId}
</Descriptions.Item>
<Descriptions.Item label="用户">
{selectedPayment.userName}
</Descriptions.Item>
<Descriptions.Item label="用户ID">
{selectedPayment.userId}
</Descriptions.Item>
<Descriptions.Item label="服务类型" span={2}>
{selectedPayment.serviceName}
</Descriptions.Item>
<Descriptions.Item label="支付金额">
{selectedPayment.currency} {selectedPayment.amount.toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="手续费">
{selectedPayment.currency} {selectedPayment.fee.toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="支付方式">
{getPaymentMethodTag(selectedPayment.paymentMethod)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{getStatusTag(selectedPayment.status)}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{dayjs(selectedPayment.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
{selectedPayment.completedAt && (
<Descriptions.Item label="完成时间" span={2}>
{dayjs(selectedPayment.completedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
)}
{selectedPayment.refundAmount && (
<>
<Descriptions.Item label="退款金额">
{selectedPayment.currency} {selectedPayment.refundAmount.toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="退款原因">
{selectedPayment.refundReason}
</Descriptions.Item>
</>
)}
</Descriptions>
)}
</Modal>
</div>
);
};
export default PaymentList;
@@ -0,0 +1,637 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Button,
Switch,
InputNumber,
Select,
Typography,
message,
Row,
Col,
Divider,
Tabs,
Space,
Alert,
Badge
} from 'antd';
import {
SaveOutlined,
ReloadOutlined,
SettingOutlined,
DollarOutlined,
PhoneOutlined,
SecurityScanOutlined,
NotificationOutlined,
GlobalOutlined
} from '@ant-design/icons';
const { Title, Text } = Typography;
const { Option } = Select;
const { TextArea } = Input;
interface SystemConfig {
// 基础设置
siteName: string;
siteDescription: string;
supportEmail: string;
supportPhone: string;
defaultLanguage: string;
timezone: string;
// Twilio 设置
twilioAccountSid: string;
twilioAuthToken: string;
twilioPhoneNumber: string;
twilioWebhookUrl: string;
enableVideoCall: boolean;
enableVoiceCall: boolean;
// 支付设置
enableAlipay: boolean;
enableWechatPay: boolean;
enableCreditCard: boolean;
enablePaypal: boolean;
paymentFeeRate: number;
minimumPayment: number;
// 业务设置
defaultCallDuration: number;
maxCallDuration: number;
translatorCommissionRate: number;
autoAssignTranslator: boolean;
requirePaymentUpfront: boolean;
// 通知设置
emailNotifications: boolean;
smsNotifications: boolean;
systemMaintenanceMode: boolean;
// 安全设置
enableTwoFactorAuth: boolean;
sessionTimeout: number;
maxLoginAttempts: number;
passwordMinLength: number;
}
const SystemSettings: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [config, setConfig] = useState<SystemConfig | null>(null);
const [activeTab, setActiveTab] = useState('basic');
// 模拟配置数据
const mockConfig: SystemConfig = {
siteName: 'Twilio翻译平台',
siteDescription: '专业的实时翻译服务平台',
supportEmail: 'support@twiliotranslate.com',
supportPhone: '400-123-4567',
defaultLanguage: 'zh-CN',
timezone: 'Asia/Shanghai',
twilioAccountSid: 'AC1234567890abcdef1234567890abcdef',
twilioAuthToken: '********************************',
twilioPhoneNumber: '+86-138-0013-8000',
twilioWebhookUrl: 'https://api.twiliotranslate.com/webhook',
enableVideoCall: true,
enableVoiceCall: true,
enableAlipay: true,
enableWechatPay: true,
enableCreditCard: true,
enablePaypal: false,
paymentFeeRate: 3.0,
minimumPayment: 10.0,
defaultCallDuration: 30,
maxCallDuration: 120,
translatorCommissionRate: 70.0,
autoAssignTranslator: true,
requirePaymentUpfront: true,
emailNotifications: true,
smsNotifications: false,
systemMaintenanceMode: false,
enableTwoFactorAuth: true,
sessionTimeout: 30,
maxLoginAttempts: 5,
passwordMinLength: 8
};
const fetchConfig = async () => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setConfig(mockConfig);
form.setFieldsValue(mockConfig);
message.success('配置加载成功');
} catch (error) {
message.error('加载配置失败');
} finally {
setLoading(false);
}
};
const handleSave = async (values: SystemConfig) => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1500));
setConfig(values);
message.success('配置保存成功');
} catch (error) {
message.error('保存配置失败');
} finally {
setLoading(false);
}
};
const testTwilioConnection = async () => {
message.loading('测试Twilio连接中...', 2);
await new Promise(resolve => setTimeout(resolve, 2000));
message.success('Twilio连接测试成功');
};
useEffect(() => {
fetchConfig();
}, []);
const renderBasicSettings = () => (
<Card title="基础设置" extra={<SettingOutlined />}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="网站名称"
name="siteName"
rules={[{ required: true, message: '请输入网站名称' }]}
>
<Input placeholder="请输入网站名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="默认语言"
name="defaultLanguage"
rules={[{ required: true, message: '请选择默认语言' }]}
>
<Select placeholder="请选择默认语言">
<Option value="zh-CN"></Option>
<Option value="en-US">English</Option>
<Option value="ja-JP"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
label="网站描述"
name="siteDescription"
>
<TextArea rows={3} placeholder="请输入网站描述" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="客服邮箱"
name="supportEmail"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input placeholder="请输入客服邮箱" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="客服电话"
name="supportPhone"
>
<Input placeholder="请输入客服电话" />
</Form.Item>
</Col>
</Row>
<Form.Item
label="时区"
name="timezone"
>
<Select placeholder="请选择时区">
<Option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</Option>
<Option value="America/New_York">America/New_York (UTC-5)</Option>
<Option value="Europe/London">Europe/London (UTC+0)</Option>
</Select>
</Form.Item>
</Card>
);
const renderTwilioSettings = () => (
<Card
title="Twilio配置"
extra={
<Space>
<Badge status="success" text="已连接" />
<Button size="small" onClick={testTwilioConnection}>
</Button>
</Space>
}
>
<Alert
message="Twilio配置说明"
description="请确保您的Twilio账户有足够的余额,并且已经验证了电话号码。"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Account SID"
name="twilioAccountSid"
rules={[{ required: true, message: '请输入Account SID' }]}
>
<Input placeholder="请输入Twilio Account SID" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Auth Token"
name="twilioAuthToken"
rules={[{ required: true, message: '请输入Auth Token' }]}
>
<Input.Password placeholder="请输入Twilio Auth Token" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="电话号码"
name="twilioPhoneNumber"
rules={[{ required: true, message: '请输入电话号码' }]}
>
<Input placeholder="请输入Twilio电话号码" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Webhook URL"
name="twilioWebhookUrl"
>
<Input placeholder="请输入Webhook URL" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="启用语音通话"
name="enableVoiceCall"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="启用视频通话"
name="enableVideoCall"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
</Card>
);
const renderPaymentSettings = () => (
<Card title="支付配置" extra={<DollarOutlined />}>
<Row gutter={16}>
<Col span={6}>
<Form.Item
label="支付宝"
name="enableAlipay"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
label="微信支付"
name="enableWechatPay"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
label="信用卡"
name="enableCreditCard"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
label="PayPal"
name="enablePaypal"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="手续费率 (%)"
name="paymentFeeRate"
rules={[{ required: true, message: '请输入手续费率' }]}
>
<InputNumber
min={0}
max={10}
step={0.1}
precision={1}
style={{ width: '100%' }}
placeholder="请输入手续费率"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="最低支付金额"
name="minimumPayment"
rules={[{ required: true, message: '请输入最低支付金额' }]}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder="请输入最低支付金额"
addonAfter="CNY"
/>
</Form.Item>
</Col>
</Row>
</Card>
);
const renderBusinessSettings = () => (
<Card title="业务配置" extra={<GlobalOutlined />}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="默认通话时长 (分钟)"
name="defaultCallDuration"
rules={[{ required: true, message: '请输入默认通话时长' }]}
>
<InputNumber
min={5}
max={180}
style={{ width: '100%' }}
placeholder="请输入默认通话时长"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="最大通话时长 (分钟)"
name="maxCallDuration"
rules={[{ required: true, message: '请输入最大通话时长' }]}
>
<InputNumber
min={10}
max={300}
style={{ width: '100%' }}
placeholder="请输入最大通话时长"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="译员佣金比例 (%)"
name="translatorCommissionRate"
rules={[{ required: true, message: '请输入译员佣金比例' }]}
>
<InputNumber
min={50}
max={90}
step={1}
style={{ width: '100%' }}
placeholder="请输入译员佣金比例"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="自动分配译员"
name="autoAssignTranslator"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item
label="要求预付费"
name="requirePaymentUpfront"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Card>
);
const renderNotificationSettings = () => (
<Card title="通知设置" extra={<NotificationOutlined />}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="邮件通知"
name="emailNotifications"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="短信通知"
name="smsNotifications"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item
label="系统维护模式"
name="systemMaintenanceMode"
valuePropName="checked"
>
<Switch />
</Form.Item>
{config?.systemMaintenanceMode && (
<Alert
message="维护模式已启用"
description="系统当前处于维护模式,用户无法正常使用服务。"
type="warning"
showIcon
/>
)}
</Card>
);
const renderSecuritySettings = () => (
<Card title="安全设置" extra={<SecurityScanOutlined />}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="启用双因素认证"
name="enableTwoFactorAuth"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="会话超时 (分钟)"
name="sessionTimeout"
rules={[{ required: true, message: '请输入会话超时时间' }]}
>
<InputNumber
min={5}
max={120}
style={{ width: '100%' }}
placeholder="请输入会话超时时间"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="最大登录尝试次数"
name="maxLoginAttempts"
rules={[{ required: true, message: '请输入最大登录尝试次数' }]}
>
<InputNumber
min={3}
max={10}
style={{ width: '100%' }}
placeholder="请输入最大登录尝试次数"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="密码最小长度"
name="passwordMinLength"
rules={[{ required: true, message: '请输入密码最小长度' }]}
>
<InputNumber
min={6}
max={20}
style={{ width: '100%' }}
placeholder="请输入密码最小长度"
/>
</Form.Item>
</Col>
</Row>
</Card>
);
const tabItems = [
{
key: 'basic',
label: '基础设置',
children: renderBasicSettings()
},
{
key: 'twilio',
label: 'Twilio配置',
children: renderTwilioSettings()
},
{
key: 'payment',
label: '支付配置',
children: renderPaymentSettings()
},
{
key: 'business',
label: '业务配置',
children: renderBusinessSettings()
},
{
key: 'notification',
label: '通知设置',
children: renderNotificationSettings()
},
{
key: 'security',
label: '安全设置',
children: renderSecuritySettings()
}
];
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
initialValues={config || {}}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
<Divider />
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
icon={<SaveOutlined />}
size="large"
>
</Button>
<Button
onClick={fetchConfig}
loading={loading}
icon={<ReloadOutlined />}
size="large"
>
</Button>
</Space>
</Form>
</div>
);
};
export default SystemSettings;
@@ -0,0 +1,477 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
Row,
Col,
Statistic,
Tooltip,
Avatar,
Rate,
Progress,
Badge
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
EditOutlined,
ReloadOutlined,
UserOutlined,
StarOutlined,
TrophyOutlined,
GlobalOutlined,
PhoneOutlined,
VideoCameraOutlined,
FileTextOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
const { Title } = Typography;
const { Option } = Select;
interface Translator {
id: string;
name: string;
email: string;
phone: string;
avatar?: string;
languages: string[];
specialties: string[];
rating: number;
totalCalls: number;
completedCalls: number;
totalEarnings: number;
status: 'available' | 'busy' | 'offline';
experience: number; // 年
certifications: string[];
joinDate: string;
lastActiveTime: string;
hourlyRate: number;
}
const TranslatorList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [translators, setTranslators] = useState<Translator[]>([]);
const [filteredTranslators, setFilteredTranslators] = useState<Translator[]>([]);
const [searchText, setSearchText] = useState('');
const [languageFilter, setLanguageFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
// 模拟数据
const mockTranslators: Translator[] = [
{
id: '1',
name: '王译员',
email: 'wang@translator.com',
phone: '13800138001',
languages: ['中文', '英文', '日文'],
specialties: ['商务', '技术', '医疗'],
rating: 4.8,
totalCalls: 156,
completedCalls: 152,
totalEarnings: 15600,
status: 'available',
experience: 5,
certifications: ['CATTI二级', '商务英语高级'],
joinDate: '2023-06-15',
lastActiveTime: '2024-01-15 14:45:00',
hourlyRate: 150
},
{
id: '2',
name: '李译员',
email: 'li@translator.com',
phone: '13800138002',
languages: ['中文', '英文', '法文'],
specialties: ['法律', '文学', '艺术'],
rating: 4.9,
totalCalls: 89,
completedCalls: 87,
totalEarnings: 12400,
status: 'busy',
experience: 7,
certifications: ['CATTI一级', '法语专业八级'],
joinDate: '2023-08-20',
lastActiveTime: '2024-01-15 13:15:00',
hourlyRate: 180
},
{
id: '3',
name: '张译员',
email: 'zhang@translator.com',
phone: '13800138003',
languages: ['中文', '德文', '俄文'],
specialties: ['工程', '科技', '学术'],
rating: 4.7,
totalCalls: 67,
completedCalls: 65,
totalEarnings: 8900,
status: 'available',
experience: 3,
certifications: ['德语专业八级', '俄语专业六级'],
joinDate: '2023-10-01',
lastActiveTime: '2024-01-15 16:20:00',
hourlyRate: 120
},
{
id: '4',
name: '赵译员',
email: 'zhao@translator.com',
phone: '13800138004',
languages: ['中文', '韩文'],
specialties: ['娱乐', '时尚', '旅游'],
rating: 4.6,
totalCalls: 45,
completedCalls: 43,
totalEarnings: 5400,
status: 'offline',
experience: 2,
certifications: ['韩语TOPIK6级'],
joinDate: '2023-11-15',
lastActiveTime: '2024-01-14 18:30:00',
hourlyRate: 100
},
{
id: '5',
name: '孙译员',
email: 'sun@translator.com',
phone: '13800138005',
languages: ['中文', '西班牙文', '葡萄牙文'],
specialties: ['体育', '新闻', '政治'],
rating: 4.5,
totalCalls: 78,
completedCalls: 74,
totalEarnings: 9200,
status: 'available',
experience: 4,
certifications: ['西语专业八级', 'DELE C2'],
joinDate: '2023-09-10',
lastActiveTime: '2024-01-15 15:10:00',
hourlyRate: 130
}
];
const fetchTranslators = async () => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setTranslators(mockTranslators);
setFilteredTranslators(mockTranslators);
message.success('译员列表加载成功');
} catch (error) {
message.error('加载译员列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTranslators();
}, []);
useEffect(() => {
let filtered = translators;
if (searchText) {
filtered = filtered.filter(translator =>
translator.name.toLowerCase().includes(searchText.toLowerCase()) ||
translator.email.toLowerCase().includes(searchText.toLowerCase()) ||
translator.languages.some(lang => lang.includes(searchText)) ||
translator.specialties.some(spec => spec.includes(searchText))
);
}
if (languageFilter !== 'all') {
filtered = filtered.filter(translator =>
translator.languages.includes(languageFilter)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(translator => translator.status === statusFilter);
}
setFilteredTranslators(filtered);
}, [translators, searchText, languageFilter, statusFilter]);
const getStatusTag = (status: string) => {
const statusConfig = {
available: { color: 'green', text: '可用' },
busy: { color: 'orange', text: '忙碌' },
offline: { color: 'red', text: '离线' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Badge status={status === 'available' ? 'success' : status === 'busy' ? 'processing' : 'error'} text={config.text} />;
};
const columns: ColumnsType<Translator> = [
{
title: '译员信息',
key: 'translatorInfo',
width: 250,
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={50}
src={record.avatar}
icon={<UserOutlined />}
style={{ marginRight: 12 }}
/>
<div>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
{record.name}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: 4 }}>
{record.email}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{record.experience}
</div>
</div>
</div>
)
},
{
title: '语言能力',
dataIndex: 'languages',
key: 'languages',
width: 200,
render: (languages) => (
<div>
{languages.map((lang: string) => (
<Tag key={lang} color="blue" style={{ marginBottom: 4 }}>
{lang}
</Tag>
))}
</div>
)
},
{
title: '专业领域',
dataIndex: 'specialties',
key: 'specialties',
width: 180,
render: (specialties) => (
<div>
{specialties.map((spec: string) => (
<Tag key={spec} color="purple" style={{ marginBottom: 4 }}>
{spec}
</Tag>
))}
</div>
)
},
{
title: '评分',
dataIndex: 'rating',
key: 'rating',
width: 120,
render: (rating) => (
<div>
<Rate disabled defaultValue={rating} style={{ fontSize: '14px' }} />
<div style={{ fontSize: '12px', color: '#666' }}>
{rating}/5.0
</div>
</div>
)
},
{
title: '工作统计',
key: 'stats',
width: 150,
render: (_, record) => (
<div>
<div style={{ fontSize: '12px', marginBottom: 4 }}>
: {record.totalCalls}
</div>
<div style={{ fontSize: '12px', marginBottom: 4 }}>
: {((record.completedCalls / record.totalCalls) * 100).toFixed(1)}%
</div>
<div style={{ fontSize: '12px' }}>
: ¥{record.totalEarnings.toLocaleString()}
</div>
</div>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: getStatusTag
},
{
title: '时薪',
dataIndex: 'hourlyRate',
key: 'hourlyRate',
width: 100,
render: (rate) => `¥${rate}/小时`
},
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
size="small"
icon={<EditOutlined />}
/>
</Tooltip>
<Tooltip title="分配任务">
<Button
size="small"
icon={<PhoneOutlined />}
disabled={record.status !== 'available'}
/>
</Tooltip>
</Space>
),
},
];
const stats = {
total: filteredTranslators.length,
available: filteredTranslators.filter(t => t.status === 'available').length,
busy: filteredTranslators.filter(t => t.status === 'busy').length,
averageRating: filteredTranslators.reduce((sum, t) => sum + t.rating, 0) / filteredTranslators.length || 0,
totalEarnings: filteredTranslators.reduce((sum, t) => sum + t.totalEarnings, 0)
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={6}>
<Card>
<Statistic
title="总译员数"
value={stats.total}
prefix={<UserOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="可用译员"
value={stats.available}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均评分"
value={stats.averageRating}
precision={1}
prefix={<StarOutlined />}
valueStyle={{ color: '#faad14' }}
suffix="/5.0"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总收入"
value={stats.totalEarnings}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<Card>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={8}>
<Input
placeholder="搜索译员姓名、邮箱、语言、专业..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={6}>
<Select
value={languageFilter}
onChange={setLanguageFilter}
style={{ width: '100%' }}
placeholder="语言筛选"
>
<Option value="all"></Option>
<Option value="英文"></Option>
<Option value="日文"></Option>
<Option value="法文"></Option>
<Option value="德文"></Option>
<Option value="韩文"></Option>
<Option value="西班牙文">西</Option>
<Option value="俄文"></Option>
</Select>
</Col>
<Col span={6}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="available"></Option>
<Option value="busy"></Option>
<Option value="offline">线</Option>
</Select>
</Col>
<Col span={4}>
<Button
icon={<ReloadOutlined />}
onClick={fetchTranslators}
loading={loading}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={filteredTranslators}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredTranslators.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
</div>
);
};
export default TranslatorList;
@@ -0,0 +1,654 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
Button,
Input,
Select,
Space,
Tag,
Typography,
Modal,
message,
DatePicker,
Row,
Col,
Statistic,
Tooltip,
Avatar,
Form,
Switch
} from 'antd';
import {
SearchOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
UserOutlined,
MailOutlined,
PhoneOutlined,
LockOutlined,
UnlockOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
const { Title } = Typography;
const { Option } = Select;
const { RangePicker } = DatePicker;
interface User {
id: string;
username: string;
email: string;
phone: string;
realName: string;
role: 'admin' | 'translator' | 'customer' | 'manager';
status: 'active' | 'inactive' | 'banned';
avatar?: string;
lastLoginTime?: string;
registrationTime: string;
totalCalls: number;
totalSpent: number;
preferredLanguages: string[];
notes?: string;
}
const UserList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
const [searchText, setSearchText] = useState('');
const [roleFilter, setRoleFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
// 模拟数据
const mockUsers: User[] = [
{
id: '1',
username: 'admin001',
email: 'admin@example.com',
phone: '13800138001',
realName: '系统管理员',
role: 'admin',
status: 'active',
lastLoginTime: '2024-01-15 15:30:00',
registrationTime: '2023-01-01 10:00:00',
totalCalls: 0,
totalSpent: 0,
preferredLanguages: ['中文', '英文'],
notes: '系统管理员账户'
},
{
id: '2',
username: 'translator_wang',
email: 'wang@translator.com',
phone: '13800138002',
realName: '王译员',
role: 'translator',
status: 'active',
lastLoginTime: '2024-01-15 14:45:00',
registrationTime: '2023-06-15 09:30:00',
totalCalls: 156,
totalSpent: 0,
preferredLanguages: ['中文', '英文', '日文'],
notes: '资深英日翻译,5年经验'
},
{
id: '3',
username: 'customer_zhang',
email: 'zhang@customer.com',
phone: '13800138003',
realName: '张先生',
role: 'customer',
status: 'active',
lastLoginTime: '2024-01-15 16:20:00',
registrationTime: '2023-12-01 14:20:00',
totalCalls: 23,
totalSpent: 1580.50,
preferredLanguages: ['中文', '英文'],
notes: '企业客户,经常需要商务翻译'
},
{
id: '4',
username: 'translator_li',
email: 'li@translator.com',
phone: '13800138004',
realName: '李译员',
role: 'translator',
status: 'active',
lastLoginTime: '2024-01-15 13:15:00',
registrationTime: '2023-08-20 11:45:00',
totalCalls: 89,
totalSpent: 0,
preferredLanguages: ['中文', '英文', '法文'],
notes: '法语专业译员'
},
{
id: '5',
username: 'customer_li',
email: 'li_customer@example.com',
phone: '13800138005',
realName: '李女士',
role: 'customer',
status: 'inactive',
lastLoginTime: '2024-01-10 10:30:00',
registrationTime: '2023-11-15 16:00:00',
totalCalls: 8,
totalSpent: 420.00,
preferredLanguages: ['中文', '韩文'],
notes: '个人用户,偶尔使用'
},
{
id: '6',
username: 'manager001',
email: 'manager@example.com',
phone: '13800138006',
realName: '业务经理',
role: 'manager',
status: 'active',
lastLoginTime: '2024-01-15 17:00:00',
registrationTime: '2023-03-01 08:00:00',
totalCalls: 0,
totalSpent: 0,
preferredLanguages: ['中文', '英文'],
notes: '负责客户关系管理'
}
];
const fetchUsers = async () => {
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
setUsers(mockUsers);
setFilteredUsers(mockUsers);
message.success('用户列表加载成功');
} catch (error) {
message.error('加载用户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
let filtered = users;
if (searchText) {
filtered = filtered.filter(user =>
user.username.toLowerCase().includes(searchText.toLowerCase()) ||
user.email.toLowerCase().includes(searchText.toLowerCase()) ||
user.realName.includes(searchText) ||
user.phone.includes(searchText)
);
}
if (roleFilter !== 'all') {
filtered = filtered.filter(user => user.role === roleFilter);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
setFilteredUsers(filtered);
}, [users, searchText, roleFilter, statusFilter]);
const getRoleTag = (role: string) => {
const roleConfig = {
admin: { color: 'red', text: '管理员' },
manager: { color: 'purple', text: '经理' },
translator: { color: 'blue', text: '译员' },
customer: { color: 'green', text: '客户' }
};
const config = roleConfig[role as keyof typeof roleConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const getStatusTag = (status: string) => {
const statusConfig = {
active: { color: 'green', text: '活跃' },
inactive: { color: 'orange', text: '非活跃' },
banned: { color: 'red', text: '已禁用' }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleStatusToggle = (userId: string, newStatus: User['status']) => {
const updatedUsers = users.map(user =>
user.id === userId ? { ...user, status: newStatus } : user
);
setUsers(updatedUsers);
message.success('用户状态更新成功');
};
const handleEdit = (user: User) => {
setEditingUser(user);
form.setFieldsValue(user);
setModalVisible(true);
};
const handleDelete = (user: User) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除用户 "${user.realName}" 吗?此操作不可恢复。`,
onOk: () => {
const newUsers = users.filter(u => u.id !== user.id);
setUsers(newUsers);
message.success('用户删除成功');
}
});
};
const handleSave = async (values: any) => {
try {
if (editingUser) {
const updatedUsers = users.map(user =>
user.id === editingUser.id ? { ...user, ...values } : user
);
setUsers(updatedUsers);
message.success('用户信息更新成功');
} else {
const newUser: User = {
id: Date.now().toString(),
...values,
registrationTime: new Date().toLocaleString(),
totalCalls: 0,
totalSpent: 0
};
setUsers([...users, newUser]);
message.success('用户创建成功');
}
setModalVisible(false);
setEditingUser(null);
form.resetFields();
} catch (error) {
message.error('保存失败');
}
};
const columns: ColumnsType<User> = [
{
title: '用户信息',
key: 'userInfo',
width: 250,
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Avatar
size={40}
src={record.avatar}
icon={<UserOutlined />}
style={{ marginRight: 12 }}
/>
<div>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
{record.realName}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
@{record.username}
</div>
</div>
</div>
)
},
{
title: '联系方式',
key: 'contact',
width: 200,
render: (_, record) => (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<MailOutlined style={{ marginRight: 4, color: '#666' }} />
<span style={{ fontSize: '12px' }}>{record.email}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<PhoneOutlined style={{ marginRight: 4, color: '#666' }} />
<span style={{ fontSize: '12px' }}>{record.phone}</span>
</div>
</div>
)
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 100,
render: getRoleTag
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: getStatusTag
},
{
title: '统计信息',
key: 'stats',
width: 150,
render: (_, record) => (
<div>
<div style={{ fontSize: '12px', marginBottom: 4 }}>
: {record.totalCalls}
</div>
<div style={{ fontSize: '12px' }}>
: ¥{record.totalSpent.toFixed(2)}
</div>
</div>
)
},
{
title: '最后登录',
dataIndex: 'lastLoginTime',
key: 'lastLoginTime',
width: 150,
render: (time) => time || '从未登录'
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="primary"
size="small"
icon={<EyeOutlined />}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title={record.status === 'active' ? '禁用' : '启用'}>
<Button
size="small"
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
onClick={() => handleStatusToggle(
record.id,
record.status === 'active' ? 'banned' : 'active'
)}
/>
</Tooltip>
<Tooltip title="删除">
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
</Tooltip>
</Space>
),
},
];
const stats = {
total: filteredUsers.length,
admin: filteredUsers.filter(u => u.role === 'admin').length,
translator: filteredUsers.filter(u => u.role === 'translator').length,
customer: filteredUsers.filter(u => u.role === 'customer').length,
active: filteredUsers.filter(u => u.status === 'active').length
};
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={5}>
<Card>
<Statistic
title="总用户数"
value={stats.total}
prefix={<UserOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={5}>
<Card>
<Statistic
title="管理员"
value={stats.admin}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={5}>
<Card>
<Statistic
title="译员"
value={stats.translator}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="客户"
value={stats.customer}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={5}>
<Card>
<Statistic
title="活跃用户"
value={stats.active}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
</Row>
<Card>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col span={6}>
<Input
placeholder="搜索用户名、邮箱、姓名、电话..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</Col>
<Col span={4}>
<Select
value={roleFilter}
onChange={setRoleFilter}
style={{ width: '100%' }}
placeholder="角色筛选"
>
<Option value="all"></Option>
<Option value="admin"></Option>
<Option value="manager"></Option>
<Option value="translator"></Option>
<Option value="customer"></Option>
</Select>
</Col>
<Col span={4}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: '100%' }}
placeholder="状态筛选"
>
<Option value="all"></Option>
<Option value="active"></Option>
<Option value="inactive"></Option>
<Option value="banned"></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker style={{ width: '100%' }} placeholder={['注册开始日期', '注册结束日期']} />
</Col>
<Col span={4}>
<Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
}}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchUsers}
loading={loading}
/>
</Space>
</Col>
</Row>
<Table
columns={columns}
dataSource={filteredUsers}
loading={loading}
rowKey="id"
scroll={{ x: 1200 }}
pagination={{
total: filteredUsers.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`
}}
/>
</Card>
<Modal
title={editingUser ? '编辑用户' : '新增用户'}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
setEditingUser(null);
form.resetFields();
}}
onOk={() => form.submit()}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="realName"
label="真实姓名"
rules={[{ required: true, message: '请输入真实姓名' }]}
>
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="phone"
label="电话"
rules={[{ required: true, message: '请输入电话号码' }]}
>
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="role"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select>
<Option value="admin"></Option>
<Option value="manager"></Option>
<Option value="translator"></Option>
<Option value="customer"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: '请选择状态' }]}
>
<Select>
<Option value="active"></Option>
<Option value="inactive"></Option>
<Option value="banned"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="preferredLanguages"
label="偏好语言"
>
<Select mode="multiple" placeholder="选择偏好语言">
<Option value="中文"></Option>
<Option value="英文"></Option>
<Option value="日文"></Option>
<Option value="韩文"></Option>
<Option value="法文"></Option>
<Option value="德文"></Option>
<Option value="西班牙文">西</Option>
<Option value="俄文"></Option>
</Select>
</Form.Item>
<Form.Item
name="notes"
label="备注"
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserList;
@@ -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();
@@ -0,0 +1,129 @@
import { twilioConfig } from '../config/twilio';
export interface TokenRequest {
identity: string;
roomName: string;
apiKey?: string;
apiSecret?: string;
}
export interface TokenResponse {
token: string;
identity: string;
roomName: string;
}
// 模拟Token生成服务
// 在实际生产环境中,这应该是一个后端API服务
export class TokenService {
// 生成访问令牌
async generateAccessToken(request: TokenRequest): Promise<TokenResponse> {
try {
// 在实际应用中,这里应该调用后端API
// 这里我们创建一个模拟的token
const mockToken = this.generateMockToken(request.identity, request.roomName);
return {
token: mockToken,
identity: request.identity,
roomName: request.roomName,
};
} catch (error) {
console.error('Error generating access token:', error);
throw new Error('Failed to generate access token');
}
}
// 生成模拟Token(仅用于开发测试)
private generateMockToken(identity: string, roomName: string): string {
// 这是一个简化的JWT模拟
// 实际应用中应该使用Twilio SDK在后端生成真实的token
const header = {
typ: 'JWT',
alg: 'HS256',
cty: 'twilio-fpa;v=1'
};
const payload = {
iss: twilioConfig.apiKey,
sub: twilioConfig.accountSid,
nbf: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1小时有效期
jti: `${twilioConfig.apiKey}-${Date.now()}`,
grants: {
identity: identity,
video: {
room: roomName
}
}
};
// 在实际应用中,这里应该用正确的签名算法
const encodedHeader = btoa(JSON.stringify(header));
const encodedPayload = btoa(JSON.stringify(payload));
const signature = btoa(`${twilioConfig.apiSecret}-signature`);
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
// 验证Token(模拟)
validateToken(token: string): boolean {
try {
const parts = token.split('.');
if (parts.length !== 3) return false;
const payload = JSON.parse(atob(parts[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp > now;
} catch {
return false;
}
}
// 解析Token信息
parseToken(token: string): { identity: string; roomName: string } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1]));
return {
identity: payload.grants?.identity || '',
roomName: payload.grants?.video?.room || '',
};
} catch {
return null;
}
}
}
export const tokenService = new TokenService();
// Express.js API端点示例(如果需要真实的后端服务)
export const createTokenEndpoint = () => {
return async (req: any, res: any) => {
try {
const { identity, roomName } = req.body;
if (!identity || !roomName) {
return res.status(400).json({
error: 'Identity and roomName are required'
});
}
const tokenResponse = await tokenService.generateAccessToken({
identity,
roomName,
});
res.json(tokenResponse);
} catch (error) {
console.error('Token generation error:', error);
res.status(500).json({
error: 'Failed to generate token'
});
}
};
};
@@ -0,0 +1,194 @@
import { connect, Room, LocalVideoTrack, LocalAudioTrack, RemoteParticipant, LocalParticipant } from 'twilio-video';
import { twilioConfig, videoOptions, RoomType, TOKEN_SERVER_URL } from '../config/twilio';
export interface TwilioToken {
token: string;
identity: string;
roomName: string;
}
export interface VideoCallOptions {
roomName: string;
identity: string;
roomType?: RoomType;
audio?: boolean;
video?: boolean;
}
export interface ParticipantInfo {
identity: string;
sid: string;
isLocal: boolean;
audioEnabled: boolean;
videoEnabled: boolean;
}
export class TwilioService {
private room: Room | null = null;
private localVideoTrack: LocalVideoTrack | null = null;
private localAudioTrack: LocalAudioTrack | null = null;
// 获取访问令牌
async getAccessToken(identity: string, roomName: string): Promise<string> {
try {
const response = await fetch(`${TOKEN_SERVER_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identity,
roomName,
apiKey: twilioConfig.apiKey,
apiSecret: twilioConfig.apiSecret,
}),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.statusText}`);
}
const data = await response.json();
return data.token;
} catch (error) {
console.error('Error getting access token:', error);
throw error;
}
}
// 连接到视频房间
async connectToRoom(options: VideoCallOptions): Promise<Room> {
try {
const token = await this.getAccessToken(options.identity, options.roomName);
const connectOptions = {
...videoOptions,
name: options.roomName,
audio: options.audio ?? true,
video: options.video ?? true,
};
this.room = await connect(token, connectOptions);
// 设置事件监听器
this.setupRoomEventListeners();
return this.room;
} catch (error) {
console.error('Error connecting to room:', error);
throw error;
}
}
// 断开连接
disconnect(): void {
if (this.room) {
this.room.disconnect();
this.room = null;
}
if (this.localVideoTrack) {
this.localVideoTrack.stop();
this.localVideoTrack = null;
}
if (this.localAudioTrack) {
this.localAudioTrack.stop();
this.localAudioTrack = null;
}
}
// 切换音频
toggleAudio(): boolean {
if (this.room && this.room.localParticipant) {
const audioTrack = Array.from(this.room.localParticipant.audioTracks.values())[0];
if (audioTrack) {
if (audioTrack.track.isEnabled) {
audioTrack.track.disable();
} else {
audioTrack.track.enable();
}
return audioTrack.track.isEnabled;
}
}
return false;
}
// 切换视频
toggleVideo(): boolean {
if (this.room && this.room.localParticipant) {
const videoTrack = Array.from(this.room.localParticipant.videoTracks.values())[0];
if (videoTrack) {
if (videoTrack.track.isEnabled) {
videoTrack.track.disable();
} else {
videoTrack.track.enable();
}
return videoTrack.track.isEnabled;
}
}
return false;
}
// 获取参与者信息
getParticipants(): ParticipantInfo[] {
if (!this.room) return [];
const participants: ParticipantInfo[] = [];
// 本地参与者
const localParticipant = this.room.localParticipant;
participants.push({
identity: localParticipant.identity,
sid: localParticipant.sid,
isLocal: true,
audioEnabled: Array.from(localParticipant.audioTracks.values()).some(track => track.track.isEnabled),
videoEnabled: Array.from(localParticipant.videoTracks.values()).some(track => track.track.isEnabled),
});
// 远程参与者
this.room.participants.forEach((participant: RemoteParticipant) => {
participants.push({
identity: participant.identity,
sid: participant.sid,
isLocal: false,
audioEnabled: Array.from(participant.audioTracks.values()).some(track => track.track && track.track.isEnabled),
videoEnabled: Array.from(participant.videoTracks.values()).some(track => track.track && track.track.isEnabled),
});
});
return participants;
}
// 获取当前房间
getCurrentRoom(): Room | null {
return this.room;
}
// 设置房间事件监听器
private setupRoomEventListeners(): void {
if (!this.room) return;
this.room.on('participantConnected', (participant: RemoteParticipant) => {
console.log('Participant connected:', participant.identity);
});
this.room.on('participantDisconnected', (participant: RemoteParticipant) => {
console.log('Participant disconnected:', participant.identity);
});
this.room.on('disconnected', (room: Room) => {
console.log('Disconnected from room:', room.name);
});
this.room.on('reconnecting', (error: any) => {
console.log('Reconnecting to room...', error);
});
this.room.on('reconnected', () => {
console.log('Reconnected to room');
});
}
}
export const twilioService = new TwilioService();
+189
View 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%
],
};
+166 -64
View File
@@ -1,3 +1,69 @@
// 用户相关类型
export interface User {
id: string;
username: string;
email: string;
phone: string;
fullName: string;
avatar?: string;
role: 'user' | 'translator' | 'admin';
status: 'active' | 'inactive' | 'suspended';
preferredLanguages: string[];
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
totalCalls: number;
totalSpent: number;
rating: number;
verificationStatus: 'pending' | 'verified' | 'rejected';
}
// 译员相关类型
export interface Translator {
id: string;
userId: string;
fullName: string;
email: string;
phone: string;
avatar?: string;
languages: string[];
specializations: string[];
status: 'available' | 'busy' | 'offline' | 'suspended';
rating: number;
totalCalls: number;
totalEarnings: number;
hourlyRate: number;
certifications: Certification[];
workingHours: WorkingHours;
createdAt: string;
updatedAt: string;
}
export interface Certification {
id: string;
name: string;
issuer: string;
issuedAt: string;
expiresAt?: string;
documentUrl?: string;
verified: boolean;
}
export interface WorkingHours {
monday: TimeSlot[];
tuesday: TimeSlot[];
wednesday: TimeSlot[];
thursday: TimeSlot[];
friday: TimeSlot[];
saturday: TimeSlot[];
sunday: TimeSlot[];
}
export interface TimeSlot {
start: string; // HH:mm
end: string; // HH:mm
}
// 通话相关类型
export interface TranslationCall {
id: string;
@@ -5,13 +71,13 @@ export interface TranslationCall {
callId: string;
clientName: string;
clientPhone: string;
type: 'human' | 'ai';
status: 'pending' | 'active' | 'completed' | 'cancelled' | 'refunded';
type: 'ai' | 'human';
status: 'pending' | 'connecting' | 'ongoing' | 'completed' | 'failed' | 'cancelled';
sourceLanguage: string;
targetLanguage: string;
startTime: string;
endTime?: string;
duration?: number;
duration?: number; // seconds
cost: number;
rating?: number;
feedback?: string;
@@ -21,14 +87,12 @@ export interface TranslationCall {
recordingUrl?: string;
transcription?: string;
translation?: string;
// 管理员相关字段
// 管理员字段
adminNotes?: string;
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount: number;
qualityScore: number;
issues: string[];
createdAt?: string;
updatedAt?: string;
refundAmount?: number;
qualityScore?: number;
issues?: string[];
}
// 文档翻译相关类型
@@ -41,11 +105,11 @@ export interface DocumentTranslation {
translatedFileUrl?: string;
sourceLanguage: string;
targetLanguage: string;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled' | 'failed';
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
progress: number;
quality: 'basic' | 'professional' | 'premium';
urgency: 'normal' | 'urgent' | 'emergency';
estimatedTime: number;
urgency: 'low' | 'normal' | 'high' | 'urgent';
estimatedTime?: number; // minutes
actualTime?: number;
cost: number;
translatorId?: string;
@@ -54,82 +118,113 @@ export interface DocumentTranslation {
feedback?: string;
createdAt: string;
completedAt?: string;
// 管理员相关字段
// 管理员字段
adminNotes?: string;
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount: number;
qualityScore: number;
issues: string[];
refundAmount?: number;
qualityScore?: number;
issues?: string[];
retranslationCount?: number;
clientName?: string;
clientEmail?: string;
clientPhone?: string;
updatedAt?: string;
}
// 预约相关类型
export interface Appointment {
id: string;
userId: string;
translatorId: string;
translatorId?: string;
title: string;
description: string;
type: string;
description?: string;
type: 'interpretation' | 'translation' | 'consultation';
sourceLanguage: string;
targetLanguage: string;
startTime: string;
endTime: string;
status: string;
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled';
cost: number;
meetingUrl?: string;
notes?: string;
reminderSent: boolean;
createdAt: string;
updatedAt?: string;
// 管理员相关字段
clientName: string;
clientEmail: string;
clientPhone: string;
translatorName: string;
translatorEmail: string;
translatorPhone: string;
updatedAt: string;
// 管理员字段
clientName?: string;
clientEmail?: string;
clientPhone?: string;
translatorName?: string;
translatorEmail?: string;
translatorPhone?: string;
adminNotes?: string;
paymentStatus: string;
refundAmount: number;
qualityScore: number;
issues: string[];
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount?: number;
qualityScore?: number;
issues?: string[];
rating?: number;
feedback?: string;
location?: string;
urgency: string;
urgency: 'low' | 'normal' | 'high' | 'urgent';
}
// 用户类型
export interface User {
// 支付相关类型
export interface Payment {
id: string;
name: string;
email: string;
phone?: string;
role: 'client' | 'translator' | 'admin';
status: 'active' | 'inactive' | 'suspended';
userId: string;
type: 'call' | 'document' | 'appointment';
relatedId: string; // callId, documentId, or appointmentId
amount: number;
currency: 'CNY' | 'USD' | 'EUR';
status: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
paymentMethod: 'wechat' | 'alipay' | 'credit_card' | 'bank_transfer';
transactionId?: string;
refundAmount?: number;
refundReason?: string;
createdAt: string;
updatedAt?: string;
completedAt?: string;
// 管理员字段
adminNotes?: string;
clientName?: string;
clientEmail?: string;
description?: string;
}
// 译员类型
export interface Translator {
id: string;
name: string;
email: string;
phone: string;
languages: string[];
specializations: string[];
rating: number;
hourlyRate: number;
status: 'available' | 'busy' | 'offline';
totalJobs: number;
successRate: number;
createdAt: string;
// 系统配置类型
export interface SystemConfig {
// 基本设置
siteName: string;
siteDescription: string;
supportEmail: string;
supportPhone: string;
// Twilio设置
twilioAccountSid: string;
twilioAuthToken: string;
twilioPhoneNumber: string;
// 支付设置
stripePublishableKey: string;
stripeSecretKey: string;
wechatPayMerchantId: string;
alipayAppId: string;
// 业务设置
defaultCallRate: number;
defaultDocumentRate: number;
maxCallDuration: number;
maxFileSize: number;
supportedLanguages: string[];
// 通知设置
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
// 安全设置
requireEmailVerification: boolean;
requirePhoneVerification: boolean;
maxLoginAttempts: number;
sessionTimeout: number;
}
// API响应类型
@@ -141,16 +236,23 @@ export interface ApiResponse<T = any> {
}
// 分页类型
export interface PaginationParams {
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
total?: number;
totalPages: number;
}
// 搜索参数类型
export interface SearchParams {
keyword?: string;
status?: string;
dateRange?: [string, string];
[key: string]: any;
// 统计数据类型
export interface DashboardStats {
totalUsers: number;
totalTranslators: number;
totalCalls: number;
totalDocuments: number;
totalRevenue: number;
activeUsers: number;
onlineTranslators: number;
ongoingCalls: number;
pendingDocuments: number;
}
+180 -105
View File
@@ -1,140 +1,226 @@
import { TranslationCall, DocumentTranslation, Appointment, ApiResponse } from '../types';
import { TranslationCall, DocumentTranslation, Appointment, ApiResponse, PaginatedResponse } from '../types';
// API基础URL
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api';
// API基础URL配置
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000/api';
// API请求工具类
class ApiManager {
// HTTP请求方法
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
// 请求选项
interface RequestOptions {
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
params?: Record<string, any>;
}
// API客户端类
export class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string = API_BASE_URL) {
constructor(baseURL = API_BASE_URL) {
this.baseURL = baseURL;
this.defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
// 通用请求方法
// 设置授权令牌
setAuthToken(token: string): void {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
// 移除授权令牌
removeAuthToken(): void {
delete this.defaultHeaders['Authorization'];
}
// 构建URL参数
private buildURL(endpoint: string, params?: Record<string, any>): string {
const url = new URL(`${this.baseURL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
// 发送HTTP请求
private async request<T>(
endpoint: string,
options: RequestInit = {}
endpoint: string,
options: RequestOptions = {}
): Promise<ApiResponse<T>> {
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
const data = await response.json();
const { method = 'GET', headers = {}, body, params } = options;
const url = this.buildURL(endpoint, params);
const requestHeaders = {
...this.defaultHeaders,
...headers,
};
if (!response.ok) {
throw new Error(data.message || '请求失败');
const requestInit: RequestInit = {
method,
headers: requestHeaders,
};
if (body && method !== 'GET') {
if (body instanceof FormData) {
// 对于FormData,不设置Content-Type,让浏览器自动设置
delete requestHeaders['Content-Type'];
requestInit.body = body;
} else {
requestInit.body = JSON.stringify(body);
}
}
const response = await fetch(url, requestInit);
// 检查响应状态
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
return {
success: true,
data,
message: '操作成功',
};
} catch (error) {
console.error('API请求错误:', error);
console.error('API请求失败:', error);
return {
success: false,
data: null as any,
message: error instanceof Error ? error.message : '网络错误',
error: error instanceof Error ? error.message : '未知错误',
};
}
}
// GET请求
async get<T>(endpoint: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'GET', params });
}
// POST请求
async post<T>(endpoint: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'POST', body });
}
// PUT请求
async put<T>(endpoint: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'PUT', body });
}
// PATCH请求
async patch<T>(endpoint: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'PATCH', body });
}
// DELETE请求
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
// 上传文件
async upload<T>(endpoint: string, file: File, additionalData?: Record<string, any>): Promise<ApiResponse<T>> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.request<T>(endpoint, {
method: 'POST',
body: formData
});
}
// 获取分页数据
async getPaginated<T>(
endpoint: string,
page = 1,
pageSize = 10,
params?: Record<string, any>
): Promise<ApiResponse<PaginatedResponse<T>>> {
const paginationParams = {
page,
pageSize,
...params,
};
return this.get<PaginatedResponse<T>>(endpoint, paginationParams);
}
// 通话管理API
async getCall(id: string): Promise<ApiResponse<TranslationCall>> {
return this.request<TranslationCall>(`/calls/${id}`);
return this.get<TranslationCall>(`/calls/${id}`);
}
async updateCall(id: string, data: Partial<TranslationCall>): Promise<ApiResponse<TranslationCall>> {
return this.request<TranslationCall>(`/calls/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.post<TranslationCall>(`/calls/${id}`, data);
}
async deleteCall(id: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/calls/${id}`, {
method: 'DELETE',
});
return this.delete<boolean>(`/calls/${id}`);
}
async processRefund(callId: string, amount: number, reason: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/calls/${callId}/refund`, {
method: 'POST',
body: JSON.stringify({ amount, reason }),
});
return this.post<boolean>(`/calls/${callId}/refund`, { amount, reason });
}
async addCallNote(callId: string, note: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/calls/${callId}/notes`, {
method: 'POST',
body: JSON.stringify({ note }),
});
return this.post<boolean>(`/calls/${callId}/notes`, { note });
}
// 文档翻译API
async getDocument(id: string): Promise<ApiResponse<DocumentTranslation>> {
return this.request<DocumentTranslation>(`/documents/${id}`);
return this.get<DocumentTranslation>(`/documents/${id}`);
}
async updateDocument(id: string, data: Partial<DocumentTranslation>): Promise<ApiResponse<DocumentTranslation>> {
return this.request<DocumentTranslation>(`/documents/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.put<DocumentTranslation>(`/documents/${id}`, data);
}
async deleteDocument(id: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/documents/${id}`, {
method: 'DELETE',
});
return this.delete<boolean>(`/documents/${id}`);
}
async reassignTranslator(documentId: string, translatorId: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/documents/${documentId}/reassign`, {
method: 'POST',
body: JSON.stringify({ translatorId }),
});
return this.post<boolean>(`/documents/${documentId}/reassign`, { translatorId });
}
async retranslateDocument(documentId: string, quality: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/documents/${documentId}/retranslate`, {
method: 'POST',
body: JSON.stringify({ quality }),
});
return this.post<boolean>(`/documents/${documentId}/retranslate`, { quality });
}
async addDocumentNote(documentId: string, note: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/documents/${documentId}/notes`, {
method: 'POST',
body: JSON.stringify({ note }),
});
return this.post<boolean>(`/documents/${documentId}/notes`, { note });
}
// 预约管理API
async getAppointment(id: string): Promise<ApiResponse<Appointment>> {
return this.request<Appointment>(`/appointments/${id}`);
return this.get<Appointment>(`/appointments/${id}`);
}
async updateAppointment(id: string, data: Partial<Appointment>): Promise<ApiResponse<Appointment>> {
return this.request<Appointment>(`/appointments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.put<Appointment>(`/appointments/${id}`, data);
}
async deleteAppointment(id: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/appointments/${id}`, {
method: 'DELETE',
});
return this.delete<boolean>(`/appointments/${id}`);
}
async rescheduleAppointment(
@@ -142,79 +228,68 @@ class ApiManager {
newStartTime: string,
newEndTime: string
): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/appointments/${appointmentId}/reschedule`, {
method: 'POST',
body: JSON.stringify({ newStartTime, newEndTime }),
});
return this.post<boolean>(`/appointments/${appointmentId}/reschedule`, { newStartTime, newEndTime });
}
async reassignAppointmentTranslator(
appointmentId: string,
translatorId: string
): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/appointments/${appointmentId}/reassign`, {
method: 'POST',
body: JSON.stringify({ translatorId }),
});
return this.post<boolean>(`/appointments/${appointmentId}/reassign`, { translatorId });
}
async addAppointmentNote(appointmentId: string, note: string): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/appointments/${appointmentId}/notes`, {
method: 'POST',
body: JSON.stringify({ note }),
});
return this.post<boolean>(`/appointments/${appointmentId}/notes`, { note });
}
// 退款处理API
async refundPayment(paymentId: string, amount: number): Promise<ApiResponse<boolean>> {
return this.request<boolean>(`/payments/${paymentId}/refund`, {
method: 'POST',
body: JSON.stringify({ amount }),
});
return this.post<boolean>(`/payments/${paymentId}/refund`, { amount });
}
// 统计数据API
async getStatistics(): Promise<ApiResponse<any>> {
return this.request<any>('/statistics');
return this.get<any>('/statistics');
}
// 用户管理API
async getUsers(page: number = 1, pageSize: number = 10): Promise<ApiResponse<any>> {
return this.request<any>(`/users?page=${page}&pageSize=${pageSize}`);
return this.get<any>(`/users?page=${page}&pageSize=${pageSize}`);
}
async updateUser(userId: string, data: any): Promise<ApiResponse<any>> {
return this.request<any>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.put<any>(`/users/${userId}`, data);
}
// 译员管理API
async getTranslators(page: number = 1, pageSize: number = 10): Promise<ApiResponse<any>> {
return this.request<any>(`/translators?page=${page}&pageSize=${pageSize}`);
return this.get<any>(`/translators?page=${page}&pageSize=${pageSize}`);
}
async updateTranslator(translatorId: string, data: any): Promise<ApiResponse<any>> {
return this.request<any>(`/translators/${translatorId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.put<any>(`/translators/${translatorId}`, data);
}
// 系统配置API
async getSystemConfig(): Promise<ApiResponse<any>> {
return this.request<any>('/config');
return this.get<any>('/config');
}
async updateSystemConfig(config: any): Promise<ApiResponse<any>> {
return this.request<any>('/config', {
method: 'PUT',
body: JSON.stringify(config),
});
return this.put<any>('/config', config);
}
}
// 导出API实例
export const api = new ApiManager();
export default api;
// 导出默认API客户端实例
export const api = new ApiClient();
// 导出常用的API方法
export const {
get,
post,
put,
patch,
delete: del,
upload,
getPaginated,
} = api;
+96 -28
View File
@@ -1,30 +1,76 @@
import { TranslationCall, DocumentTranslation, Appointment, User, Translator } from '../types';
// 模拟数据库连接
class DatabaseManager {
private isConnected: boolean = false;
// 模拟数据库连接和操作
export class Database {
private connected = false;
// 连接数据库
async connect(): Promise<void> {
if (!this.isConnected) {
// 模拟连接延迟
await new Promise(resolve => setTimeout(resolve, 100));
this.isConnected = true;
console.log('数据库连接成功');
}
// 模拟数据库连接
await new Promise(resolve => setTimeout(resolve, 100));
this.connected = true;
}
// 断开数据库连接
async disconnect(): Promise<void> {
if (this.isConnected) {
this.isConnected = false;
console.log('数据库连接已断开');
}
this.connected = false;
}
// 检查连接状态
isConnectionActive(): boolean {
return this.isConnected;
isConnected(): boolean {
return this.connected;
}
// 模拟查询操作
async query<T>(sql: string, params?: any[]): Promise<T[]> {
if (!this.connected) {
throw new Error('Database not connected');
}
// 模拟查询延迟
await new Promise(resolve => setTimeout(resolve, 50));
// 这里可以添加具体的查询逻辑
return [] as T[];
}
// 模拟插入操作
async insert<T>(table: string, data: Partial<T>): Promise<T> {
if (!this.connected) {
throw new Error('Database not connected');
}
await new Promise(resolve => setTimeout(resolve, 50));
// 模拟返回插入的数据
return {
...data,
id: `${table}_${Date.now()}`,
createdAt: new Date().toISOString(),
} as T;
}
// 模拟更新操作
async update<T>(table: string, id: string, data: Partial<T>): Promise<T> {
if (!this.connected) {
throw new Error('Database not connected');
}
await new Promise(resolve => setTimeout(resolve, 50));
// 模拟返回更新的数据
return {
...data,
id,
updatedAt: new Date().toISOString(),
} as T;
}
// 模拟删除操作
async delete(table: string, id: string): Promise<boolean> {
if (!this.connected) {
throw new Error('Database not connected');
}
await new Promise(resolve => setTimeout(resolve, 50));
return true;
}
// 通话相关操作
@@ -59,7 +105,6 @@ class DatabaseManager {
refundAmount: 0,
qualityScore: 0,
issues: [],
createdAt: new Date().toISOString(),
};
return newCall;
}
@@ -149,7 +194,7 @@ class DatabaseManager {
translatorId: data.translatorId || '',
title: data.title || '',
description: data.description || '',
type: data.type || '',
type: data.type || 'interpretation',
sourceLanguage: data.sourceLanguage || '',
targetLanguage: data.targetLanguage || '',
startTime: data.startTime || new Date().toISOString(),
@@ -158,6 +203,7 @@ class DatabaseManager {
cost: data.cost || 0,
reminderSent: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
clientName: data.clientName || '',
clientEmail: data.clientEmail || '',
clientPhone: data.clientPhone || '',
@@ -203,12 +249,21 @@ class DatabaseManager {
// 模拟创建用户
const newUser: User = {
id: `user_${Date.now()}`,
name: data.name || '',
username: data.username || '',
email: data.email || '',
phone: data.phone,
role: data.role || 'client',
phone: data.phone || '',
fullName: data.fullName || '',
avatar: data.avatar,
role: data.role || 'user',
status: 'active',
preferredLanguages: data.preferredLanguages || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastLoginAt: data.lastLoginAt,
totalCalls: data.totalCalls || 0,
totalSpent: data.totalSpent || 0,
rating: data.rating || 0,
verificationStatus: data.verificationStatus || 'pending',
};
return newUser;
}
@@ -243,17 +298,30 @@ class DatabaseManager {
// 模拟创建译员
const newTranslator: Translator = {
id: `translator_${Date.now()}`,
name: data.name || '',
userId: data.userId || '',
fullName: data.fullName || '',
email: data.email || '',
phone: data.phone || '',
avatar: data.avatar,
languages: data.languages || [],
specializations: data.specializations || [],
status: data.status || 'available',
rating: data.rating || 0,
totalCalls: data.totalCalls || 0,
totalEarnings: data.totalEarnings || 0,
hourlyRate: data.hourlyRate || 0,
status: 'available',
totalJobs: 0,
successRate: 0,
certifications: data.certifications || [],
workingHours: data.workingHours || {
monday: [],
tuesday: [],
wednesday: [],
thursday: [],
friday: [],
saturday: [],
sunday: [],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return newTranslator;
}
@@ -286,5 +354,5 @@ class DatabaseManager {
}
// 导出单例实例
export const database = new DatabaseManager();
export const database = new Database();
export default database;
+32 -2
View File
@@ -4,8 +4,38 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twilio 翻译服务管理后台</title>
<meta name="description" content="Twilio 翻译服务管理后台系统" />
<title>翻译通 - 移动端</title>
<meta name="description" content="专业的翻译服务平台" />
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
height: 100vh;
width: 100vw;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-content {
flex: 1;
overflow-y: auto;
padding-bottom: 60px;
}
</style>
</head>
<body>
<div id="root"></div>
+305 -1
View File
@@ -12,6 +12,7 @@
"@ant-design/plots": "^2.5.0",
"@reduxjs/toolkit": "^1.9.7",
"@tanstack/react-query": "^5.8.4",
"@twilio/conversations": "^2.6.2",
"@types/moment": "^2.13.0",
"@types/react-native-vector-icons": "^6.4.18",
"antd": "^5.12.5",
@@ -33,7 +34,7 @@
"recharts": "^2.8.0",
"socket.io-client": "^4.7.4",
"stripe": "^14.7.0",
"twilio-video": "^2.28.1",
"twilio-video": "^2.31.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
@@ -2011,6 +2012,128 @@
"react": "^18 || ^19"
}
},
"node_modules/@twilio/conversations": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@twilio/conversations/-/conversations-2.6.2.tgz",
"integrity": "sha512-xbikMRIiDeVxthchThAp2aL3BDHxCd6gqDkpQU4cwMHkzktxceuTC3NoBmV6HtDHzt1xjvct2WxBKw/ZPJj8Xw==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"@twilio/declarative-type-validator": "^0.2.10",
"@twilio/deprecation-decorator": "^0.2.8",
"@twilio/mcs-client": "^0.6.10",
"@twilio/notifications": "^2.0.9",
"@twilio/operation-retrier": "^4.0.18",
"@twilio/replay-event-emitter": "^0.3.10",
"core-js": "^3.17.3",
"iso8601-duration": "=1.2.0",
"isomorphic-form-data": "^2.0.0",
"lodash.isequal": "^4.5.0",
"loglevel": "^1.8.0",
"platform": "^1.3.6",
"quick-lru": "^5.1.1",
"twilio-sync": "~3.1.0",
"twilsock": "~0.12.2",
"uuid": "^3.4.0",
"xmlhttprequest": "^1.8.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/declarative-type-validator": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@twilio/declarative-type-validator/-/declarative-type-validator-0.2.10.tgz",
"integrity": "sha512-q3ep+qsctZ0u+xr1U6/kjs4RlIJ/u8+wHyUQkrNCoVtjgnCV938P0RP7OUkW/fYt/vLhfy0Nnzo1G0bBbn9o/w==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"core-js": "^3.17.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/deprecation-decorator": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@twilio/deprecation-decorator/-/deprecation-decorator-0.2.8.tgz",
"integrity": "sha512-kDWN6sxOisTMQXQL0JgvzqGjNZIUj+hdZ1eZtm/5z6EMb14xuoi6qSXb//0x6Yn02zSrr6c7K8YKDrUawDRabQ==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"core-js": "^3.17.3",
"loglevel": "1.8.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/deprecation-decorator/node_modules/loglevel": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz",
"integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/@twilio/mcs-client": {
"version": "0.6.10",
"resolved": "https://registry.npmjs.org/@twilio/mcs-client/-/mcs-client-0.6.10.tgz",
"integrity": "sha512-MrZtvxyChUXUpcHG+BR/hY3u573/HsBtMMrTQE4HRvy3v9ZYY0RhHrECqW2TRw0iA3R9quk0CNrz357fjg8I+w==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"@twilio/declarative-type-validator": "^0.2.10",
"@twilio/operation-retrier": "^4.0.18",
"core-js": "^3.17.3",
"loglevel": "^1.8.0",
"xmlhttprequest": "^1.8.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/notifications": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@twilio/notifications/-/notifications-2.0.9.tgz",
"integrity": "sha512-sHuiIwSPx9xMo6y2l1p+lISs7kWt299B7AOsFqhoetgQr/Pz2PePwKLDruch9OWtW8ZNnT/vOpoz7qbN15vvmA==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"@twilio/declarative-type-validator": "^0.2.10",
"@twilio/operation-retrier": "^4.0.18",
"core-js": "^3.17.3",
"loglevel": "^1.8.0",
"twilsock": "~0.12.2",
"uuid": "^3.4.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/operation-retrier": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@twilio/operation-retrier/-/operation-retrier-4.0.18.tgz",
"integrity": "sha512-vG3i41XEa4lyC3+8FRFbjYBPZQftkI1WrJTtTDBf85N2UzZ8brqrUp9EbSdQmny1/zIvoZ18AswQrvBDLtNEvA==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"core-js": "^3.17.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@twilio/replay-event-emitter": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/@twilio/replay-event-emitter/-/replay-event-emitter-0.3.10.tgz",
"integrity": "sha512-GaT5ihN3eJvIgCV81ggvLgtNwMtD2pBR4hMrFE/dlqN73FsNw01bawLSMIofOdRj8ydEPgvSTJHVCIjBWKHQvg==",
"dependencies": {
"@babel/runtime": "^7.17.0",
"core-js": "^3.17.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3197,6 +3320,11 @@
"node": "*"
}
},
"node_modules/async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3613,6 +3741,16 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-js": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz",
"integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -5455,6 +5593,39 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/iso8601-duration": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-1.2.0.tgz",
"integrity": "sha512-ErTBd++b17E8nmWII1K1uZtBgD1E8RjyvwmxlCjPHNqHMD7gmcMHOw0E8Ro/6+QT4PhHRSnnMo7bxa1vFPkwhg=="
},
"node_modules/isomorphic-form-data": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz",
"integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==",
"dependencies": {
"form-data": "^2.3.2"
}
},
"node_modules/isomorphic-form-data/node_modules/form-data": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz",
"integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/javascript-state-machine": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/javascript-state-machine/-/javascript-state-machine-3.1.0.tgz",
"integrity": "sha512-BwhYxQ1OPenBPXC735RgfB+ZUG8H3kjsx8hrYTgWnoy6TPipEy4fiicyhT2lxRKAXq9pG7CfFT8a2HLr6Hmwxg=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5644,12 +5815,30 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -6627,6 +6816,11 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
"node_modules/platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6810,6 +7004,17 @@
}
]
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
@@ -7888,6 +8093,25 @@
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
@@ -8449,6 +8673,37 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/twilio-sync": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/twilio-sync/-/twilio-sync-3.1.0.tgz",
"integrity": "sha512-KNkbbnoBITpsmxV2UnmNDEot/Q5t7p5I1zP05oqj0OYT1kMcZq4nhiSNkcxkunfxINFSUzz8d/mUA82yWS7iLQ==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"@twilio/declarative-type-validator": "^0.1.11",
"@twilio/operation-retrier": "^4.0.7",
"core-js": "^3.17.3",
"iso8601-duration": "=1.2.0",
"loglevel": "^1.6.3",
"platform": "^1.3.6",
"twilsock": "^0.12.2",
"uuid": "^3.4.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/twilio-sync/node_modules/@twilio/declarative-type-validator": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@twilio/declarative-type-validator/-/declarative-type-validator-0.1.11.tgz",
"integrity": "sha512-yRAMLPD8j3k67UFvPeZvfTlKYuceiNq+iZ8a/ADzAbZMeaV0FMvsJmG97MH8yN/VdXY9hcscchsnc99bJ1sClw==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"core-js": "^3.17.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/twilio-video": {
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/twilio-video/-/twilio-video-2.31.0.tgz",
@@ -8463,6 +8718,46 @@
"node": ">=0.12"
}
},
"node_modules/twilsock": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/twilsock/-/twilsock-0.12.2.tgz",
"integrity": "sha512-7G59f2TCEnxcY2ZBCzaZOPmMDoxDrK9lMTiA7UvuiKca37Dljbdlu2EHI3+d7gU1JHkH5GNCmyxqJzSbZodwXA==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"@twilio/declarative-type-validator": "^0.1.11",
"@twilio/operation-retrier": "^4.0.7",
"core-js": "^3.17.3",
"iso8601-duration": "=1.2.0",
"javascript-state-machine": "^3.1.0",
"loglevel": "^1.6.3",
"platform": "^1.3.6",
"uuid": "^3.4.0",
"ws": "^5.2.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/twilsock/node_modules/@twilio/declarative-type-validator": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@twilio/declarative-type-validator/-/declarative-type-validator-0.1.11.tgz",
"integrity": "sha512-yRAMLPD8j3k67UFvPeZvfTlKYuceiNq+iZ8a/ADzAbZMeaV0FMvsJmG97MH8yN/VdXY9hcscchsnc99bJ1sClw==",
"dependencies": {
"@babel/runtime": "^7.14.5",
"core-js": "^3.17.3"
},
"engines": {
"node": ">=14"
}
},
"node_modules/twilsock/node_modules/ws": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ws/-/ws-5.2.4.tgz",
"integrity": "sha512-fFCejsuC8f9kOSu9FYaOw8CdO68O3h5v0lg4p74o8JqWpwTf9tniOD+nOB78aWoVSS6WptVUmDrp/KPsMVBWFQ==",
"dependencies": {
"async-limiter": "~1.0.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -8702,6 +8997,15 @@
"which-typed-array": "^1.1.2"
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+2 -1
View File
@@ -15,6 +15,7 @@
"@ant-design/plots": "^2.5.0",
"@reduxjs/toolkit": "^1.9.7",
"@tanstack/react-query": "^5.8.4",
"@twilio/conversations": "^2.6.2",
"@types/moment": "^2.13.0",
"@types/react-native-vector-icons": "^6.4.18",
"antd": "^5.12.5",
@@ -36,7 +37,7 @@
"recharts": "^2.8.0",
"socket.io-client": "^4.7.4",
"stripe": "^14.7.0",
"twilio-video": "^2.28.1",
"twilio-video": "^2.31.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
+99
View 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
View File
File diff suppressed because it is too large Load Diff
+28
View 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"
}
+4 -1
View File
@@ -1,8 +1,10 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { AppProvider } from '@/store';
import AppRoutes from '@/routes';
import DeviceTestPanel from '@/components/DeviceTestPanel';
import '@/styles/global.css';
// Ant Design 主题配置
@@ -25,7 +27,7 @@ const theme = {
},
};
const App = () => {
const App: React.FC = () => {
return (
<ConfigProvider
locale={zhCN}
@@ -35,6 +37,7 @@ const App = () => {
<AppProvider>
<BrowserRouter>
<AppRoutes />
<DeviceTestPanel />
</BrowserRouter>
</AppProvider>
</AntdApp>
+48
View 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
View 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
View 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;
+58 -59
View File
@@ -8,11 +8,10 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ path: '/mobile/home', label: '首页', icon: '🏠' },
{ path: '/mobile/call', label: '通话', icon: '📞' },
{ path: '/mobile/documents', label: '文档', icon: '📄' },
{ path: '/mobile/appointments', label: '预约', icon: '📅' },
{ path: '/mobile/settings', label: '设置', icon: '⚙️' },
{ path: '/home', label: '首页', icon: '🏠' },
{ path: '/call', label: '通话', icon: '📞' },
{ path: '/documents', label: '文档', icon: '📄' },
{ path: '/settings', label: '我的', icon: '👤' },
];
const MobileNavigation: FC = () => {
@@ -23,6 +22,60 @@ const MobileNavigation: FC = () => {
navigate(path);
};
const styles = {
container: {
display: 'flex',
flexDirection: 'row' as const,
backgroundColor: '#fff',
borderTop: '1px solid #e0e0e0',
padding: '8px 4px',
justifyContent: 'space-around',
alignItems: 'center',
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
height: '80px',
boxShadow: '0 -2px 4px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
},
navItem: {
flex: 1,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '8px 4px',
borderRadius: '8px',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s ease',
},
activeNavItem: {
backgroundColor: '#f0f8ff',
},
icon: {
fontSize: '20px',
marginBottom: '4px',
opacity: 0.6,
transition: 'opacity 0.2s ease',
},
activeIcon: {
opacity: 1,
},
label: {
fontSize: '12px',
color: '#666',
textAlign: 'center' as const,
transition: 'color 0.2s ease',
},
activeLabel: {
color: '#1890ff',
fontWeight: '600' as const,
},
};
return (
<div style={styles.container}>
{navItems.map((item) => {
@@ -55,58 +108,4 @@ const MobileNavigation: FC = () => {
);
};
const styles = {
container: {
display: 'flex',
flexDirection: 'row' as const,
backgroundColor: '#fff',
borderTop: '1px solid #e0e0e0',
padding: '8px 4px',
justifyContent: 'space-around',
alignItems: 'center',
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
height: '80px',
boxShadow: '0 -2px 4px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
},
navItem: {
flex: 1,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '8px 4px',
borderRadius: '8px',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s ease',
},
activeNavItem: {
backgroundColor: '#f0f8ff',
},
icon: {
fontSize: '20px',
marginBottom: '4px',
opacity: 0.6,
transition: 'opacity 0.2s ease',
},
activeIcon: {
opacity: 1,
},
label: {
fontSize: '12px',
color: '#666',
textAlign: 'center' as const,
transition: 'color 0.2s ease',
},
activeLabel: {
color: '#1890ff',
fontWeight: '600' as const,
},
};
export default MobileNavigation;
+4 -5
View File
@@ -8,11 +8,10 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ path: '/mobile/home', label: '首页', icon: '🏠' },
{ path: '/mobile/call', label: '通话', icon: '📞' },
{ path: '/mobile/documents', label: '文档', icon: '📄' },
{ path: '/mobile/appointments', label: '预约', icon: '📅' },
{ path: '/mobile/settings', label: '设置', icon: '⚙️' },
{ path: '/home', label: '首页', icon: '🏠' },
{ path: '/call', label: '通话', icon: '📞' },
{ path: '/documents', label: '文档', icon: '📄' },
{ path: '/settings', label: '我的', icon: '👤' },
];
const MobileNavigation: FC = () => {
+523
View File
@@ -0,0 +1,523 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Card, Row, Col, Space, Typography, message, Badge, Statistic } from 'antd';
import {
PhoneOutlined,
VideoCameraOutlined,
AudioOutlined,
AudioMutedOutlined,
VideoCameraAddOutlined,
StopOutlined,
WalletOutlined,
ClockCircleOutlined
} from '@ant-design/icons';
import { Room, RemoteParticipant, LocalParticipant } from 'twilio-video';
import { twilioService, VideoCallOptions, ParticipantInfo } from '../../services/twilioService';
import { BillingService } from '../../services/billingService';
import { CallType, TranslationType, UserAccount, UserType } from '../../types/billing';
const { Title, Text } = Typography;
interface VideoCallProps {
roomName: string;
identity: string;
callType?: CallType;
translationType?: TranslationType;
onLeave?: () => void;
onBillingUpdate?: (cost: number, duration: number) => void;
}
interface ParticipantVideoProps {
participant: RemoteParticipant | LocalParticipant;
isLocal: boolean;
}
const ParticipantVideo: React.FC<ParticipantVideoProps> = ({ participant, isLocal }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
let videoTrack: any = null;
let audioTrack: any = null;
// 获取第一个视频轨道
participant.videoTracks.forEach((track) => {
if (!videoTrack) videoTrack = track;
});
// 获取第一个音频轨道
participant.audioTracks.forEach((track) => {
if (!audioTrack) audioTrack = track;
});
if (videoTrack && videoRef.current) {
videoTrack.track?.attach(videoRef.current);
}
if (audioTrack && audioRef.current && !isLocal) {
audioTrack.track?.attach(audioRef.current);
}
return () => {
if (videoTrack?.track) {
videoTrack.track.detach();
}
if (audioTrack?.track && !isLocal) {
audioTrack.track.detach();
}
};
}, [participant, isLocal]);
return (
<div className="participant-video" style={{ position: 'relative', width: '100%', height: '200px' }}>
<video
ref={videoRef}
autoPlay
muted={isLocal}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '8px',
}}
/>
{!isLocal && <audio ref={audioRef} autoPlay />}
<div
style={{
position: 'absolute',
bottom: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
}}
>
{participant.identity} {isLocal && '(您)'}
</div>
</div>
);
};
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 [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 [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 () => {
if (isConnecting) return;
// 检查余额
if (!checkBalance()) {
return;
}
setIsConnecting(true);
try {
const options: VideoCallOptions = {
roomName,
identity,
audio: audioEnabled,
video: videoEnabled,
};
const connectedRoom = await twilioService.connectToRoom(options);
setRoom(connectedRoom);
setIsConnected(true);
setupRoomEventListeners(connectedRoom);
startBilling(); // 开始计费
message.success('成功连接到视频通话');
} catch (error) {
console.error('连接失败:', error);
message.error('连接视频通话失败');
} finally {
setIsConnecting(false);
}
};
const setupRoomEventListeners = (room: Room) => {
const updateParticipants = () => {
setParticipants(twilioService.getParticipants());
};
room.on('participantConnected', (participant: RemoteParticipant) => {
message.info(`${participant.identity} 加入了通话`);
updateParticipants();
});
room.on('participantDisconnected', (participant: RemoteParticipant) => {
message.info(`${participant.identity} 离开了通话`);
updateParticipants();
});
room.on('disconnected', () => {
setIsConnected(false);
setRoom(null);
setParticipants([]);
stopBilling(); // 停止计费
message.info('已断开视频通话连接');
});
updateParticipants();
};
const leaveRoom = () => {
twilioService.disconnect();
setIsConnected(false);
setRoom(null);
setParticipants([]);
stopBilling(); // 停止计费
onLeave?.();
};
const toggleAudio = () => {
const newAudioState = twilioService.toggleAudio();
setAudioEnabled(newAudioState);
};
const toggleVideo = () => {
const newVideoState = twilioService.toggleVideo();
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(() => {
return () => {
twilioService.disconnect();
stopBilling();
};
}, []);
if (!isConnected) {
return (
<Card style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<div style={{ textAlign: 'center' }}>
<Title level={4}></Title>
<Text>: {roomName}</Text>
<br />
<Text>: {identity}</Text>
<br />
<Text>: {callType === CallType.VIDEO ? '视频通话' : '语音通话'}</Text>
<br />
<Text>: {translationType === TranslationType.SIGN_LANGUAGE ? '手语翻译' : translationType === TranslationType.TEXT ? '文字翻译' : '真人翻译'}</Text>
<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%' }}>
<Row gutter={16}>
<Col span={12}>
<Button
type={audioEnabled ? 'primary' : 'default'}
icon={audioEnabled ? <AudioOutlined /> : <AudioMutedOutlined />}
onClick={() => setAudioEnabled(!audioEnabled)}
style={{ width: '100%' }}
>
{audioEnabled ? '音频开启' : '音频关闭'}
</Button>
</Col>
<Col span={12}>
<Button
type={videoEnabled ? 'primary' : 'default'}
icon={<VideoCameraOutlined />}
onClick={() => setVideoEnabled(!videoEnabled)}
style={{ width: '100%' }}
>
{videoEnabled ? '视频开启' : '视频关闭'}
</Button>
</Col>
</Row>
<Button
type="primary"
size="large"
icon={<VideoCameraAddOutlined />}
loading={isConnecting}
onClick={connectToRoom}
style={{ width: '100%' }}
disabled={!userAccount}
>
{isConnecting ? '连接中...' : '加入通话'}
</Button>
</Space>
</div>
</Card>
);
}
return (
<div style={{ width: '100%', height: '100vh', padding: '16px' }}>
<Card style={{ height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 通话信息和计费状态 */}
<div style={{ marginBottom: '16px' }}>
<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 style={{ flex: 1, overflow: 'auto' }}>
<Row gutter={[16, 16]}>
{participants.map((participantInfo) => {
const participant = participantInfo.isLocal
? room?.localParticipant
: Array.from(room?.participants.values() || []).find(p => p.sid === participantInfo.sid);
if (!participant) return null;
return (
<Col xs={24} sm={12} md={8} key={participantInfo.sid}>
<ParticipantVideo
participant={participant}
isLocal={participantInfo.isLocal}
/>
</Col>
);
})}
</Row>
</div>
{/* 控制按钮 */}
<div style={{ marginTop: '16px', textAlign: 'center' }}>
<Space size="large">
<Button
type={audioEnabled ? 'default' : 'primary'}
danger={!audioEnabled}
icon={audioEnabled ? <AudioOutlined /> : <AudioMutedOutlined />}
onClick={toggleAudio}
size="large"
>
{audioEnabled ? '静音' : '取消静音'}
</Button>
<Button
type={videoEnabled ? 'default' : 'primary'}
danger={!videoEnabled}
icon={<VideoCameraOutlined />}
onClick={toggleVideo}
size="large"
>
{videoEnabled ? '关闭视频' : '开启视频'}
</Button>
<Button
danger
type="primary"
icon={<StopOutlined />}
onClick={leaveRoom}
size="large"
>
</Button>
</Space>
</div>
</div>
</Card>
</div>
);
};
+349
View 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;
+51
View File
@@ -0,0 +1,51 @@
export interface TwilioConfig {
apiKey: string;
apiSecret: string;
accountSid: string;
videoServiceSid?: string;
conversationServiceSid?: string;
}
// Twilio配置
export const twilioConfig: TwilioConfig = {
apiKey: 'SK3b25e00e6914162a7cf829cffc415cb3',
apiSecret: 'PpGH298dlRgMSeGrexUjw1flczTVIw9H',
accountSid: 'AC_YOUR_ACCOUNT_SID', // 需要从Twilio控制台获取
videoServiceSid: '', // 可选:视频服务SID
conversationServiceSid: '', // 可选:对话服务SID
};
// Token服务器URL(开发环境)
export const TOKEN_SERVER_URL = process.env.NODE_ENV === 'production'
? 'https://your-production-server.com/api/twilio/token'
: 'http://localhost:3001/api/twilio/token';
// 视频配置选项
export const videoOptions = {
audio: true,
video: {
width: 640,
height: 480,
frameRate: 24,
},
bandwidthProfile: {
video: {
mode: 'collaboration' as const,
maxTracks: 10,
},
},
dominantSpeaker: true,
networkQuality: {
local: 1,
remote: 1,
},
};
// 房间类型
export enum RoomType {
GROUP = 'group',
GROUP_SMALL = 'group-small',
PEER_TO_PEER = 'peer-to-peer',
}
export default twilioConfig;
+7 -6
View File
@@ -1,15 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '../App.tsx';
import './styles/global.css';
// 创建根元素
const root = createRoot(
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
// 渲染应用
root.render(
<StrictMode>
<React.StrictMode>
<App />
</StrictMode>
</React.StrictMode>
);
+12 -26
View File
@@ -8,23 +8,17 @@ import { View, Text, StyleSheet } from 'react-native';
import HomeScreen from '@/screens/HomeScreen';
import CallScreen from '@/screens/CallScreen';
import DocumentScreen from '@/screens/DocumentScreen';
import AppointmentScreen from '@/screens/AppointmentScreen';
import SettingsScreen from '@/screens/SettingsScreen';
// 导航类型定义
export type RootStackParamList = {
MainTabs: undefined;
Call: {
mode: 'ai' | 'human' | 'video' | 'sign';
sourceLanguage: string;
targetLanguage: string;
};
};
export type TabParamList = {
Home: undefined;
Call: undefined;
Documents: undefined;
Appointments: undefined;
Settings: undefined;
};
@@ -37,12 +31,12 @@ const TabIcon: React.FC<{ name: string; focused: boolean }> = ({ name, focused }
switch (iconName) {
case 'home':
return '🏠';
case 'call':
return '📞';
case 'documents':
return '📄';
case 'appointments':
return '📅';
case 'settings':
return '⚙️';
return '👤';
default:
return '❓';
}
@@ -79,6 +73,13 @@ const TabNavigator: React.FC = () => {
tabBarLabel: '首页',
}}
/>
<Tab.Screen
name="Call"
component={CallScreen}
options={{
tabBarLabel: '通话',
}}
/>
<Tab.Screen
name="Documents"
component={DocumentScreen}
@@ -86,18 +87,11 @@ const TabNavigator: React.FC = () => {
tabBarLabel: '文档',
}}
/>
<Tab.Screen
name="Appointments"
component={AppointmentScreen}
options={{
tabBarLabel: '预约',
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarLabel: '设置',
tabBarLabel: '我的',
}}
/>
</Tab.Navigator>
@@ -118,14 +112,6 @@ const AppNavigator: React.FC = () => {
name="MainTabs"
component={TabNavigator}
/>
<Stack.Screen
name="Call"
component={CallScreen}
options={{
presentation: 'fullScreenModal',
gestureEnabled: false,
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
+146
View File
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { Card, Form, Input, Button, Space, Typography, message } from 'antd';
import { VideoCameraOutlined, UserOutlined, HomeOutlined } from '@ant-design/icons';
import { VideoCall } from '../../components/VideoCall/VideoCall';
const { Title, Text } = Typography;
export const VideoCallPage: React.FC = () => {
const [isInCall, setIsInCall] = useState(false);
const [roomName, setRoomName] = useState('');
const [identity, setIdentity] = useState('');
const [form] = Form.useForm();
const handleJoinCall = (values: { roomName: string; identity: string }) => {
if (!values.roomName.trim() || !values.identity.trim()) {
message.error('请填写房间名称和用户名');
return;
}
setRoomName(values.roomName.trim());
setIdentity(values.identity.trim());
setIsInCall(true);
};
const handleLeaveCall = () => {
setIsInCall(false);
setRoomName('');
setIdentity('');
form.resetFields();
};
if (isInCall) {
return (
<VideoCall
roomName={roomName}
identity={identity}
onLeave={handleLeaveCall}
/>
);
}
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<Card
style={{
width: '100%',
maxWidth: '400px',
borderRadius: '16px',
boxShadow: '0 20px 40px rgba(0,0,0,0.1)'
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<VideoCameraOutlined
style={{
fontSize: '48px',
color: '#1890ff',
marginBottom: '16px'
}}
/>
<Title level={2} style={{ margin: 0, color: '#1f1f1f' }}>
</Title>
<Text type="secondary">
</Text>
</div>
<Form
form={form}
layout="vertical"
onFinish={handleJoinCall}
size="large"
>
<Form.Item
name="roomName"
label="房间名称"
rules={[
{ required: true, message: '请输入房间名称' },
{ min: 3, message: '房间名称至少3个字符' }
]}
>
<Input
prefix={<HomeOutlined />}
placeholder="输入房间名称"
style={{ borderRadius: '8px' }}
/>
</Form.Item>
<Form.Item
name="identity"
label="您的姓名"
rules={[
{ required: true, message: '请输入您的姓名' },
{ min: 2, message: '姓名至少2个字符' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="输入您的姓名"
style={{ borderRadius: '8px' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
icon={<VideoCameraOutlined />}
style={{
width: '100%',
height: '48px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '500'
}}
>
</Button>
</Form.Item>
</Form>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f8f9fa',
borderRadius: '8px'
}}>
<Text type="secondary" style={{ fontSize: '12px' }}>
<strong>使</strong><br />
<br />
<br />
/<br />
</Text>
</div>
</Card>
</div>
);
};
+580
View 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
View 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
View 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}>
PDFDOCDOCXTXT
</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
View 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
View 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
View 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;
+44 -35
View File
@@ -3,13 +3,23 @@ import { AppLayout } from '@/components/Layout';
import { Dashboard, UserList, CallList } from '@/pages';
import { useAuth } from '@/store';
// 导入移动端页面 - 使用Web版本
import HomeScreen from '@/screens/HomeScreen.web';
import CallScreen from '@/screens/CallScreen.web';
import DocumentScreen from '@/screens/DocumentScreen.web';
import AppointmentScreen from '@/screens/AppointmentScreen.web';
import SettingsScreen from '@/screens/SettingsScreen.web';
import MobileNavigation from '@/components/MobileNavigation.web';
// 导入移动端组件
import MobileLayout from '@/components/MobileLayout';
import MobileHome from '@/pages/mobile/Home';
import MobileCall from '@/pages/mobile/Call';
import MobileDocuments from '@/pages/mobile/Documents';
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';
// 私有路由组件
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
@@ -22,7 +32,7 @@ const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuth();
return !isAuthenticated ? <>{children}</> : <Navigate to="/dashboard" replace />;
return !isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
};
// 登录页面(临时占位符)
@@ -55,25 +65,6 @@ const NotFoundPage = () => (
</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 = () => {
return (
<Routes>
@@ -87,6 +78,22 @@ const AppRoutes = () => {
}
/>
{/* 视频通话测试路由 - 独立访问 */}
<Route
path="/video-test"
element={<VideoCallTest />}
/>
{/* 根路径 - 智能重定向 */}
<Route
path="/"
element={
<PrivateRoute>
<DeviceRedirect />
</PrivateRoute>
}
/>
{/* 移动端路由 */}
<Route
path="/mobile/*"
@@ -95,11 +102,13 @@ const AppRoutes = () => {
<MobileLayout>
<Routes>
<Route path="/" element={<Navigate to="/mobile/home" replace />} />
<Route path="/home" element={<HomeScreen />} />
<Route path="/call" element={<CallScreen />} />
<Route path="/documents" element={<DocumentScreen />} />
<Route path="/appointments" element={<AppointmentScreen />} />
<Route path="/settings" element={<SettingsScreen />} />
<Route path="/home" element={<MobileHome />} />
<Route path="/call" element={<MobileCall />} />
<Route path="/documents" element={<MobileDocuments />} />
<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 />} />
</Routes>
</MobileLayout>
@@ -114,9 +123,6 @@ const AppRoutes = () => {
<PrivateRoute>
<AppLayout>
<Routes>
{/* 默认重定向到仪表板 */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 仪表板 */}
<Route path="/dashboard" element={<Dashboard />} />
@@ -126,6 +132,9 @@ const AppRoutes = () => {
{/* 通话记录 */}
<Route path="/calls" element={<CallList />} />
{/* 视频通话测试 */}
<Route path="/video-test" element={<VideoCallTest />} />
{/* 文档管理 - 待实现 */}
<Route
path="/documents"
+268 -82
View File
@@ -9,20 +9,47 @@ import {
Dimensions,
StatusBar,
} from 'react-native';
import { mockLanguages } from '@/utils/mockData';
import { Language, CallSession } from '@/types';
import { BillingService } from '../services/billingService';
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 {
route?: {
params?: {
mode: 'ai' | 'human' | 'video' | 'sign';
sourceLanguage: string;
targetLanguage: string;
mode?: string;
sourceLanguage?: string;
targetLanguage?: string;
};
};
navigation?: any;
navigation?: {
goBack: () => void;
};
}
const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
@@ -33,14 +60,40 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
const [callDuration, setCallDuration] = useState(0);
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 startTime = useRef<Date | null>(null);
const billingInterval = useRef<NodeJS.Timeout | null>(null);
// 从路由参数获取通话配置
const callMode = route?.params?.mode || 'ai';
const sourceLanguage = route?.params?.sourceLanguage || 'zh';
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 => {
return mockLanguages.find(lang => lang.code === code);
@@ -49,6 +102,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
const sourceLang = getLanguageInfo(sourceLanguage);
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(() => {
// 模拟连接过程
connectToCall();
@@ -57,13 +125,100 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
if (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 () => {
try {
setIsConnecting(true);
// 检查余额
if (!checkBalance()) {
return;
}
// 模拟获取Twilio token和连接过程
// const tokenResponse = await apiService.getTwilioToken(callMode);
// const callResponse = await apiService.startCall({
@@ -77,6 +232,7 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
setIsConnecting(false);
setIsConnected(true);
startCallTimer();
startBilling(); // 开始计费
// 创建模拟通话会话
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')}`;
};
const formatCost = (cents: number): string => {
return (cents / 100).toFixed(2);
};
const handleEndCall = () => {
Alert.alert(
'结束通话',
'确定要结束当前通话吗?',
`通话时长: ${formatDuration(callDuration)}\n费用: ¥${formatCost(currentCost)}\n确定要结束当前通话吗?`,
[
{ text: '取消', style: 'cancel' },
{
@@ -139,6 +299,9 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
if (callTimer.current) {
clearInterval(callTimer.current);
}
if (billingInterval.current) {
clearInterval(billingInterval.current);
}
// 在实际应用中调用API结束通话
// if (currentCall) {
@@ -196,19 +359,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="#1a1a1a" />
<View style={styles.connectingContainer}>
<Text style={styles.connectingIcon}>📞</Text>
<Text style={styles.connectingTitle}>...</Text>
<Text style={styles.connectingSubtitle}>
{getModeTitle(callMode)}
<Text style={styles.connectingText}>...</Text>
<Text style={styles.modeText}>{getModeTitle(callMode)}</Text>
<Text style={styles.languageText}>
{sourceLang?.nativeName} {targetLang?.nativeName}
</Text>
<View style={styles.languageInfo}>
<Text style={styles.languageText}>
{sourceLang?.flag} {sourceLang?.nativeName} {targetLang?.flag} {targetLang?.nativeName}
</Text>
</View>
<TouchableOpacity style={styles.cancelButton} onPress={() => navigation?.goBack()}>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
{userAccount && (
<View style={styles.balanceInfo}>
<Text style={styles.balanceText}>
: ¥{formatCost(userAccount.balance)}
</Text>
<Text style={styles.rateText}>
: ¥{formatCost(billingService.calculateCallCost(callType, translationType, 1))}/
</Text>
</View>
)}
</View>
</SafeAreaView>
);
@@ -229,7 +394,15 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
</Text>
</View>
</View>
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
<View style={styles.callStats}>
<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>
{/* 视频区域 */}
@@ -265,31 +438,31 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
{/* 控制按钮 */}
<View style={styles.controlsContainer}>
<TouchableOpacity
style={[styles.controlButton, isMuted && styles.controlButtonActive]}
style={[styles.controlButton, isMuted && styles.mutedButton]}
onPress={toggleMute}
>
<Text style={styles.controlButtonText}>
{isMuted ? '🔇' : '🔊'}
{isMuted ? '🔇' : '🎤'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
>
<Text style={styles.endCallButtonText}>📞</Text>
</TouchableOpacity>
{(callMode === 'video' || callMode === 'sign') && (
<TouchableOpacity
style={[styles.controlButton, !isVideoEnabled && styles.controlButtonActive]}
style={[styles.controlButton, !isVideoEnabled && styles.mutedButton]}
onPress={toggleVideo}
>
<Text style={styles.controlButtonText}>
{isVideoEnabled ? '📹' : '📵'}
{isVideoEnabled ? '📹' : '📷'}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.controlButton, styles.endCallButton]}
onPress={handleEndCall}
>
<Text style={styles.controlButtonText}>📞</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
@@ -304,82 +477,87 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
padding: 20,
},
connectingIcon: {
fontSize: 80,
marginBottom: 24,
},
connectingTitle: {
connectingText: {
fontSize: 24,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
textAlign: 'center',
marginBottom: 20,
},
connectingSubtitle: {
fontSize: 16,
modeText: {
fontSize: 18,
color: '#ccc',
marginBottom: 32,
textAlign: 'center',
marginBottom: 10,
},
languageInfo: {
languageText: {
fontSize: 16,
color: '#999',
marginBottom: 20,
},
balanceInfo: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
padding: 16,
borderRadius: 12,
marginBottom: 32,
},
languageText: {
balanceText: {
fontSize: 18,
color: '#fff',
textAlign: 'center',
},
cancelButton: {
backgroundColor: '#F44336',
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 24,
},
cancelButtonText: {
color: '#fff',
rateText: {
fontSize: 16,
fontWeight: 'bold',
color: '#ccc',
textAlign: 'center',
},
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
padding: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
callInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
modeIcon: {
fontSize: 32,
fontSize: 24,
marginRight: 12,
},
callDetails: {
flex: 1,
},
callTitle: {
fontSize: 16,
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 2,
},
languagesText: {
fontSize: 14,
color: '#ccc',
},
callStats: {
alignItems: 'flex-end',
},
duration: {
fontSize: 24,
fontWeight: 'bold',
color: '#fff',
},
cost: {
fontSize: 18,
fontWeight: 'bold',
color: '#4CAF50',
},
balance: {
fontSize: 14,
color: '#ccc',
},
videoContainer: {
flex: 1,
padding: 20,
},
videoArea: {
flex: 1,
@@ -388,13 +566,15 @@ const styles = StyleSheet.create({
remoteVideo: {
flex: 1,
backgroundColor: '#333',
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
localVideo: {
position: 'absolute',
top: 16,
right: 16,
top: 20,
right: 20,
width: 120,
height: 160,
backgroundColor: '#555',
@@ -403,9 +583,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
videoPlaceholder: {
color: '#ccc',
color: '#fff',
fontSize: 16,
textAlign: 'center',
},
audioOnlyArea: {
flex: 1,
@@ -413,52 +592,59 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
audioIcon: {
fontSize: 120,
marginBottom: 24,
fontSize: 80,
marginBottom: 20,
},
audioTitle: {
fontSize: 20,
fontWeight: 'bold',
fontSize: 24,
color: '#fff',
marginBottom: 32,
textAlign: 'center',
marginBottom: 40,
},
waveform: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 60,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
waveBar: {
width: 4,
backgroundColor: '#4CAF50',
marginHorizontal: 2,
borderRadius: 2,
},
controlsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
paddingHorizontal: 40,
paddingBottom: 40,
gap: 40,
},
controlButton: {
width: 64,
height: 64,
borderRadius: 32,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 16,
},
controlButtonActive: {
mutedButton: {
backgroundColor: '#F44336',
},
controlButtonText: {
fontSize: 24,
},
endCallButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#F44336',
justifyContent: 'center',
alignItems: 'center',
},
endCallButtonText: {
fontSize: 32,
transform: [{ rotate: '135deg' }],
},
});
export default CallScreen;
export default CallScreen;
+229
View 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
View 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;
}
}
+194
View File
@@ -0,0 +1,194 @@
import { connect, Room, LocalVideoTrack, LocalAudioTrack, RemoteParticipant, LocalParticipant } from 'twilio-video';
import { twilioConfig, videoOptions, RoomType, TOKEN_SERVER_URL } from '../config/twilio';
export interface TwilioToken {
token: string;
identity: string;
roomName: string;
}
export interface VideoCallOptions {
roomName: string;
identity: string;
roomType?: RoomType;
audio?: boolean;
video?: boolean;
}
export interface ParticipantInfo {
identity: string;
sid: string;
isLocal: boolean;
audioEnabled: boolean;
videoEnabled: boolean;
}
export class TwilioService {
private room: Room | null = null;
private localVideoTrack: LocalVideoTrack | null = null;
private localAudioTrack: LocalAudioTrack | null = null;
// 获取访问令牌
async getAccessToken(identity: string, roomName: string): Promise<string> {
try {
const response = await fetch(`${TOKEN_SERVER_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identity,
roomName,
apiKey: twilioConfig.apiKey,
apiSecret: twilioConfig.apiSecret,
}),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.statusText}`);
}
const data = await response.json();
return data.token;
} catch (error) {
console.error('Error getting access token:', error);
throw error;
}
}
// 连接到视频房间
async connectToRoom(options: VideoCallOptions): Promise<Room> {
try {
const token = await this.getAccessToken(options.identity, options.roomName);
const connectOptions = {
...videoOptions,
name: options.roomName,
audio: options.audio ?? true,
video: options.video ?? true,
};
this.room = await connect(token, connectOptions);
// 设置事件监听器
this.setupRoomEventListeners();
return this.room;
} catch (error) {
console.error('Error connecting to room:', error);
throw error;
}
}
// 断开连接
disconnect(): void {
if (this.room) {
this.room.disconnect();
this.room = null;
}
if (this.localVideoTrack) {
this.localVideoTrack.stop();
this.localVideoTrack = null;
}
if (this.localAudioTrack) {
this.localAudioTrack.stop();
this.localAudioTrack = null;
}
}
// 切换音频
toggleAudio(): boolean {
if (this.room && this.room.localParticipant) {
const audioTrack = Array.from(this.room.localParticipant.audioTracks.values())[0];
if (audioTrack) {
if (audioTrack.track.isEnabled) {
audioTrack.track.disable();
} else {
audioTrack.track.enable();
}
return audioTrack.track.isEnabled;
}
}
return false;
}
// 切换视频
toggleVideo(): boolean {
if (this.room && this.room.localParticipant) {
const videoTrack = Array.from(this.room.localParticipant.videoTracks.values())[0];
if (videoTrack) {
if (videoTrack.track.isEnabled) {
videoTrack.track.disable();
} else {
videoTrack.track.enable();
}
return videoTrack.track.isEnabled;
}
}
return false;
}
// 获取参与者信息
getParticipants(): ParticipantInfo[] {
if (!this.room) return [];
const participants: ParticipantInfo[] = [];
// 本地参与者
const localParticipant = this.room.localParticipant;
participants.push({
identity: localParticipant.identity,
sid: localParticipant.sid,
isLocal: true,
audioEnabled: Array.from(localParticipant.audioTracks.values()).some(track => track.track.isEnabled),
videoEnabled: Array.from(localParticipant.videoTracks.values()).some(track => track.track.isEnabled),
});
// 远程参与者
this.room.participants.forEach((participant: RemoteParticipant) => {
participants.push({
identity: participant.identity,
sid: participant.sid,
isLocal: false,
audioEnabled: Array.from(participant.audioTracks.values()).some(track => track.track && track.track.isEnabled),
videoEnabled: Array.from(participant.videoTracks.values()).some(track => track.track && track.track.isEnabled),
});
});
return participants;
}
// 获取当前房间
getCurrentRoom(): Room | null {
return this.room;
}
// 设置房间事件监听器
private setupRoomEventListeners(): void {
if (!this.room) return;
this.room.on('participantConnected', (participant: RemoteParticipant) => {
console.log('Participant connected:', participant.identity);
});
this.room.on('participantDisconnected', (participant: RemoteParticipant) => {
console.log('Participant disconnected:', participant.identity);
});
this.room.on('disconnected', (room: Room) => {
console.log('Disconnected from room:', room.name);
});
this.room.on('reconnecting', (error: any) => {
console.log('Reconnecting to room...', error);
});
this.room.on('reconnected', () => {
console.log('Reconnected to room');
});
}
}
export const twilioService = new TwilioService();
+121
View 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
View 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
View 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
View 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
View 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>
+8 -2
View File
@@ -8,21 +8,27 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
// React Native Web 别名配置
'react-native$': 'react-native-web',
'react-native': 'react-native-web',
'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$': 'react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter',
'react-native/Libraries/vendor/emitter/EventEmitter$': 'react-native-web/dist/vendor/react-native/emitter/EventEmitter',
'react-native/Libraries/EventEmitter/NativeEventEmitter$': 'react-native-web/dist/vendor/react-native/NativeEventEmitter',
},
extensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'],
},
esbuild: {
// 在开发环境中忽略一些TypeScript错误
target: 'esnext',
logOverride: { 'this-is-undefined-in-esm': 'silent' }
},
define: {
// React Native Web 需要的全局变量
global: 'globalThis',
__DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
},
server: {
port: 3000,
port: 5173,
host: true,
open: true
},
build: {
outDir: 'dist',