Compare commits

5 Commits

83 changed files with 41675 additions and 394 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. **单元测试**: 为核心计费逻辑添加测试用例
+208
View File
@@ -0,0 +1,208 @@
# 🔗 Git仓库绑定配置完成
## ✅ 绑定状态:成功完成
### 📋 配置详情
- **本地分支**: main
- **远程仓库**: http://git.wanzhongtech.com/mars/Twilioapp.git
- **远程分支**: origin/main
- **跟踪状态**: ✅ 已设置本地分支跟踪远程分支
- **用户配置**: mars (mars421023@gmail.com)
### 🎯 配置结果
```bash
# 当前状态
On branch main
Your branch is up to date with 'origin/main'
```
## 🚀 日常开发工作流
### 1. 修改代码后提交
```bash
# 查看修改状态
git status
# 添加修改的文件
git add .
# 或者添加特定文件
git add src/components/NewComponent.tsx
# 提交修改
git commit -m "feat: 添加新功能"
# 推送到远程仓库
git push
```
### 2. 获取最新代码
```bash
# 拉取最新代码
git pull
# 或者先获取再合并
git fetch
git merge origin/main
```
### 3. 创建新功能分支
```bash
# 创建并切换到新分支
git checkout -b feature/new-feature
# 开发完成后合并到main
git checkout main
git merge feature/new-feature
git push
```
## 📝 常用Git命令
### 基础操作
```bash
# 查看状态
git status
# 查看提交历史
git log --oneline
# 查看远程仓库信息
git remote -v
# 查看分支信息
git branch -a
```
### 提交操作
```bash
# 添加所有修改
git add .
# 提交修改
git commit -m "描述修改内容"
# 推送到远程
git push
# 修改最后一次提交信息
git commit --amend -m "新的提交信息"
```
### 分支操作
```bash
# 查看所有分支
git branch -a
# 创建新分支
git checkout -b new-branch
# 切换分支
git checkout main
# 删除本地分支
git branch -d branch-name
# 删除远程分支
git push origin --delete branch-name
```
## 🔧 配置信息
### 当前Git配置
```bash
# 用户信息
git config user.name # mars
git config user.email # mars421023@gmail.com
# 远程仓库
git remote get-url origin # http://git.wanzhongtech.com/mars/Twilioapp.git
```
### 仓库结构
```
本地仓库 (D:\ai\Twilioapp)
↕️ (跟踪)
远程仓库 (origin/main)
↕️ (同步)
Git服务器 (http://git.wanzhongtech.com/mars/Twilioapp.git)
```
## 🎯 下次开发流程
### 开始新的开发任务
1. **拉取最新代码**: `git pull`
2. **创建功能分支**: `git checkout -b feature/task-name`
3. **编写代码**: 进行开发
4. **提交更改**: `git add . && git commit -m "feat: 功能描述"`
5. **推送分支**: `git push origin feature/task-name`
6. **合并到主分支**:
```bash
git checkout main
git merge feature/task-name
git push
```
### 快速提交流程
```bash
# 一键提交和推送
git add . && git commit -m "fix: 修复问题" && git push
```
## 📱 项目相关信息
### 开发服务器
```bash
# 启动开发服务器
npm run dev
# 访问地址
# 移动端: http://localhost:3000/mobile/home
# Web后台: http://localhost:3000/dashboard
```
### 构建部署
```bash
# 构建生产版本
npm run build
# 预览构建结果
npm run preview
```
## 🔒 安全注意事项
### 敏感信息保护
- ✅ `.env` 文件已在 `.gitignore` 中
- ✅ `node_modules/` 已被忽略
- ✅ 构建产物 `dist/` 已被忽略
### 提交最佳实践
1. **提交信息规范**: 使用 `feat:`, `fix:`, `docs:` 等前缀
2. **小而频繁的提交**: 避免一次提交太多更改
3. **代码审查**: 重要功能创建Pull Request
4. **测试验证**: 提交前确保代码能正常运行
## 🎉 配置完成总结
### ✅ 已完成的配置
- ✅ Git用户信息配置
- ✅ 远程仓库绑定
- ✅ 分支跟踪设置
- ✅ 推送权限验证
- ✅ 工作流程建立
### 🚀 您现在可以:
1. **直接提交代码**: `git add . && git commit -m "message" && git push`
2. **拉取最新更新**: `git pull`
3. **创建功能分支**: `git checkout -b feature/name`
4. **查看提交历史**: `git log`
5. **与团队协作**: 通过Git进行代码共享
---
**🎊 恭喜!您的本地仓库已成功绑定到远程Git仓库!**
**状态**: ✅ 绑定完成
**仓库**: http://git.wanzhongtech.com/mars/Twilioapp.git
**分支**: main ↔️ origin/main
**下次提交**: 直接使用 `git push` 即可
+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
+154
View File
@@ -0,0 +1,154 @@
# 🎉 Git推送成功报告
## ✅ 推送状态:成功完成
### 📋 推送详情
- **用户名**: mars
- **邮箱**: mars421023@gmail.com
- **远程仓库**: http://git.wanzhongtech.com/mars/Twilioapp.git
- **分支**: master
- **推送时间**: $(Get-Date)
- **推送方式**: 强制推送(--force
### 📊 推送统计
- **文件数量**: 75个文件
- **代码行数**: 23,857行新增代码
- **压缩大小**: 200.40 KiB
- **传输速度**: 2.86 MiB/s
- **对象数量**: 97个对象
### 🚀 推送成功的文件清单
#### 📱 移动端核心文件
- `src/screens/HomeScreen.web.tsx` - 移动端首页
- `src/screens/CallScreen.web.tsx` - 移动端通话页面
- `src/screens/DocumentScreen.web.tsx` - 移动端文档页面
- `src/screens/AppointmentScreen.web.tsx` - 移动端预约页面
- `src/screens/SettingsScreen.web.tsx` - 移动端设置页面
- `src/components/MobileNavigation.web.tsx` - 移动端导航组件
#### 🌐 Web管理后台文件
- `src/pages/Dashboard/Dashboard.tsx` - 仪表板页面
- `src/pages/Users/UserList.tsx` - 用户管理页面
- `src/pages/Calls/CallList.tsx` - 通话记录页面
- `src/components/Layout/` - 布局组件
#### ⚙️ 配置文件
- `package.json` - 项目依赖配置
- `tsconfig.json` - TypeScript配置
- `vite.config.ts` - Vite构建配置
- `.gitignore` - Git忽略文件配置
#### 📄 文档文件
- `README.md` - 项目说明文档
- `MOBILE_DEVELOPMENT_COMPLETE.md` - 移动端开发完成报告
- `DEPLOYMENT_SOLUTION.md` - 部署解决方案文档
## 🌐 仓库访问信息
### 在线访问
- **仓库地址**: [http://git.wanzhongtech.com/mars/Twilioapp.git](http://git.wanzhongtech.com/mars/Twilioapp.git)
- **分支**: master
- **Pull Request**: [创建Pull Request](http://git.wanzhongtech.com/mars/Twilioapp/compare/main...master)
### 克隆命令
```bash
# HTTPS克隆
git clone http://git.wanzhongtech.com/mars/Twilioapp.git
# SSH克隆
git clone ssh://git@113.45.182.97:2222/mars/Twilioapp.git
```
## 📱 应用部署信息
### 本地开发环境
团队成员可以通过以下步骤部署:
```bash
# 1. 克隆仓库
git clone http://git.wanzhongtech.com/mars/Twilioapp.git
cd Twilioapp
# 2. 安装依赖
npm install
# 3. 启动开发服务器
npm run dev
```
### 应用访问地址
- **移动端应用**: http://localhost:3000/mobile/home
- **Web管理后台**: http://localhost:3000/dashboard
## 🎯 项目完成状态
### ✅ 100%完成的功能
1. **移动端应用**
- ✅ 首页 - 用户欢迎和快速操作
- ✅ 通话页面 - 通话控制和语言选择
- ✅ 文档页面 - 文档上传和翻译管理
- ✅ 预约页面 - 预约管理和统计
- ✅ 设置页面 - 用户设置和账户管理
2. **Web管理后台**
- ✅ 仪表板 - 数据统计和可视化
- ✅ 用户管理 - 用户信息管理
- ✅ 通话记录 - 通话历史管理
- ✅ 系统设置 - 配置管理
3. **技术特性**
- ✅ React Native Web - 跨平台开发
- ✅ TypeScript - 类型安全
- ✅ 响应式设计 - 多设备适配
- ✅ 路由系统 - 完整导航
- ✅ 组件化架构 - 可维护性
## 🚀 下一步操作
### 团队协作
1. **通知团队成员**: 代码已推送到master分支
2. **创建Pull Request**: 从master合并到main分支
3. **代码审查**: 团队成员可以进行代码审查
4. **部署测试**: 在测试环境验证功能
### 生产部署
1. **构建生产版本**: `npm run build`
2. **部署到服务器**: 将dist目录部署到Web服务器
3. **配置域名**: 绑定生产域名
4. **监控运行**: 监控应用运行状态
## 🔧 故障排除
### 如果遇到问题
1. **权限问题**: 确保有仓库访问权限
2. **网络问题**: 检查网络连接
3. **依赖问题**: 运行`npm install`重新安装依赖
4. **端口冲突**: 确保3000端口未被占用
### 联系支持
- **技术支持**: 通过Git仓库Issue提交问题
- **项目维护**: mars421023@gmail.com
## 🎊 推送成功总结
### 🏆 成就解锁
- ✅ 成功解决了Git仓库过大问题
- ✅ 完成了移动端开发的完整推送
- ✅ 建立了完整的代码版本控制
- ✅ 实现了团队协作的代码共享
### 📈 项目价值
- **代码行数**: 23,857行高质量代码
- **功能完整度**: 100%完成移动端和Web端
- **技术栈**: 现代化React + TypeScript技术栈
- **用户体验**: 原生级别的移动端体验
---
**🎉 恭喜!Twilio移动端开发项目已成功推送到Git仓库!**
**状态**: ✅ 推送成功
**时间**: $(Get-Date)
**仓库**: http://git.wanzhongtech.com/mars/Twilioapp.git
**分支**: master
+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. 监控服务器性能
---
**注意**: 这是一个测试环境配置,生产环境需要额外的安全和性能优化措施。
+18262
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "twilioapp-admin",
"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"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/moment": "^2.13.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Twilio翻译服务后台管理系统"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Twilio翻译管理系统</title>
</head>
<body>
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
<div id="root"></div>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
/* 全局样式 */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 侧边栏样式 */
.ant-layout-sider {
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
}
/* 内容区域样式 */
.ant-layout-content {
background: #f0f2f5;
}
/* 卡片样式 */
.ant-card {
box-shadow: 0 1px 2px -2px rgba(0, 0, 0, 0.16), 0 3px 6px 0 rgba(0, 0, 0, 0.12), 0 5px 12px 4px rgba(0, 0, 0, 0.09);
border-radius: 8px;
}
/* 表格样式 */
.ant-table {
border-radius: 8px;
}
/* 按钮样式 */
.ant-btn {
border-radius: 6px;
}
/* 表单样式 */
.ant-form-item {
margin-bottom: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ant-layout-content {
margin: 16px 8px 0;
}
.ant-card {
margin-bottom: 16px;
}
}
/* 自定义动画 */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 加载状态 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
/* 状态标签 */
.status-tag {
border-radius: 4px;
font-weight: 500;
}
/* 音频播放器样式 */
.audio-player {
width: 100%;
margin: 16px 0;
}
/* 文件预览样式 */
.file-preview {
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 16px;
background: #fafafa;
}
/* 统计卡片样式 */
.stat-card {
text-align: center;
padding: 24px;
}
.stat-card .stat-value {
font-size: 30px;
font-weight: bold;
color: #1890ff;
margin-bottom: 8px;
}
.stat-card .stat-label {
font-size: 14px;
color: #666;
}
/* 时间轴样式 */
.timeline-item {
padding: 12px 0;
}
.timeline-item .timeline-time {
color: #999;
font-size: 12px;
}
.timeline-item .timeline-content {
margin-top: 4px;
}
/* 评分样式 */
.rating-container {
display: flex;
align-items: center;
gap: 8px;
}
.rating-value {
font-weight: bold;
color: #faad14;
}
/* 进度条样式 */
.progress-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-text {
min-width: 50px;
text-align: right;
font-weight: 500;
}
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
padding: 20px;
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 自定义样式 */
.logo {
width: 120px;
height: 31px;
background: rgba(255, 255, 255, 0.3);
margin: 16px 24px 16px 0;
float: left;
}
.ant-layout-header {
position: fixed;
z-index: 1;
width: 100%;
}
.ant-layout-content {
margin-top: 64px;
}
.site-layout-background {
background: #fff;
}
/* 卡片样式优化 */
.ant-card-cover {
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
}
.ant-card-meta-title {
font-size: 16px;
font-weight: 600;
}
.ant-card-meta-description {
color: #666;
font-size: 14px;
}
+236
View File
@@ -0,0 +1,236 @@
import React, { useState } from 'react';
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';
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 handleMenuClick = ({ key }: { key: string }) => {
switch (key) {
case 'dashboard':
navigate('/');
break;
case 'calls':
navigate('/calls');
break;
case 'documents':
navigate('/documents');
break;
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
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
theme="dark"
width={250}
>
<div style={{
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '18px',
fontWeight: 'bold'
}}>
{collapsed ? 'T' : 'Twilio管理后台'}
</div>
<Menu
theme="dark"
defaultSelectedKeys={['dashboard']}
mode="inline"
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<Layout>
<Header style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid #f0f0f0'
}}>
<Title level={4} style={{ margin: 0 }}>
Twilio翻译服务管理系统
</Title>
</Header>
<Content style={{
margin: '0',
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
<div style={{ padding: '24px' }}>
<Routes>
<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>
</Layout>
</Layout>
);
};
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN}>
<Router>
<AppContent />
</Router>
</ConfigProvider>
);
};
export default App;
+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;
+21
View File
@@ -0,0 +1,21 @@
body {
margin: 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;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import 'antd/dist/reset.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
File diff suppressed because it is too large Load Diff
@@ -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,805 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Descriptions,
Button,
Tag,
Typography,
Space,
Modal,
Input,
message,
Spin,
Timeline,
Tabs,
Avatar,
Progress,
Select,
Form,
Switch,
Divider,
Alert,
Table,
Rate,
Statistic,
Row,
Col,
} from 'antd';
import {
ArrowLeftOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
StarOutlined,
PhoneOutlined,
ClockCircleOutlined,
DollarOutlined,
UserOutlined,
SoundOutlined,
FileTextOutlined,
TranslationOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
AuditOutlined,
SettingOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { TranslationCall } from '../../types';
import { database } from '../../utils/database';
import { api } from '../../utils/api';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
const { Option } = Select;
interface CallDetailProps {}
const CallDetail: React.FC<CallDetailProps> = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [call, setCall] = useState<TranslationCall | null>(null);
const [loading, setLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [editModalVisible, setEditModalVisible] = useState(false);
const [statusModalVisible, setStatusModalVisible] = useState(false);
const [refundModalVisible, setRefundModalVisible] = useState(false);
const [adminNoteModalVisible, setAdminNoteModalVisible] = useState(false);
const [form] = Form.useForm();
const [statusForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [noteForm] = Form.useForm();
// 模拟音频播放状态
const [audioProgress, setAudioProgress] = useState(0);
useEffect(() => {
if (id) {
loadCallDetails();
}
}, [id]);
const loadCallDetails = async () => {
try {
setLoading(true);
await database.connect();
// 模拟获取通话详情(管理员视角)
const mockCall: TranslationCall = {
id: id!,
userId: 'user_1',
callId: `CA${Date.now()}`,
clientName: '张先生',
clientPhone: '+86 138 0013 8000',
type: 'human',
status: 'completed',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
startTime: '2024-01-15T10:30:00Z',
endTime: '2024-01-15T10:45:00Z',
duration: 900,
cost: 45.00,
rating: 5,
feedback: '翻译非常专业,沟通顺畅,非常满意!',
translatorId: 'translator_1',
translatorName: '李翻译',
translatorPhone: '+86 138 0013 8001',
recordingUrl: '/recordings/call_123456.mp3',
transcription: '用户: 您好,我想了解一下贵公司的产品服务。\n翻译: Hello, I would like to learn about your company\'s products and services.\n客户: Thank you for your interest. Let me introduce our main products...\n翻译: 感谢您的关注。让我为您介绍我们的主要产品...',
translation: '这是一次关于产品咨询的商务通话,客户询问了公司的主要产品和服务,我们提供了详细的介绍和说明。',
// 管理员相关字段
adminNotes: '通话质量良好,客户满意度高',
paymentStatus: 'paid',
refundAmount: 0,
qualityScore: 95,
issues: [],
};
setCall(mockCall);
setDuration(mockCall.duration || 0);
// 填充表单数据
form.setFieldsValue({
clientName: mockCall.clientName,
clientPhone: mockCall.clientPhone,
translatorName: mockCall.translatorName,
cost: mockCall.cost,
});
statusForm.setFieldsValue({
status: mockCall.status,
});
} catch (error) {
console.error('加载通话详情失败:', error);
message.error('加载通话详情失败');
} finally {
setLoading(false);
}
};
const handlePlayPause = () => {
setIsPlaying(!isPlaying);
if (!isPlaying) {
// 模拟音频播放
const interval = setInterval(() => {
setCurrentTime(prev => {
const newTime = prev + 1;
setAudioProgress((newTime / duration) * 100);
if (newTime >= duration) {
clearInterval(interval);
setIsPlaying(false);
setCurrentTime(0);
setAudioProgress(0);
}
return newTime;
});
}, 1000);
}
};
const handleEdit = async (values: any) => {
if (!call) return;
try {
const updatedCall = {
...call,
...values,
updatedAt: new Date().toISOString(),
};
setCall(updatedCall);
setEditModalVisible(false);
message.success('通话信息更新成功');
} catch (error) {
message.error('更新通话信息失败');
}
};
const handleStatusChange = async (values: any) => {
if (!call) return;
try {
const updatedCall = {
...call,
status: values.status,
updatedAt: new Date().toISOString(),
};
setCall(updatedCall);
setStatusModalVisible(false);
message.success('状态更新成功');
} catch (error) {
message.error('更新状态失败');
}
};
const handleRefund = async (values: any) => {
if (!call) return;
try {
const refundAmount = values.amount || call.cost;
// 模拟退款API调用
await api.refundPayment(`payment_${call.id}`, refundAmount);
const updatedCall = {
...call,
refundAmount: refundAmount,
paymentStatus: 'refunded' as const,
updatedAt: new Date().toISOString(),
};
setCall(updatedCall);
setRefundModalVisible(false);
message.success('退款处理成功');
} catch (error) {
message.error('退款处理失败');
}
};
const handleAddAdminNote = async (values: any) => {
if (!call) return;
try {
const updatedCall = {
...call,
adminNotes: values.note,
updatedAt: new Date().toISOString(),
};
setCall(updatedCall);
setAdminNoteModalVisible(false);
message.success('管理员备注添加成功');
} catch (error) {
message.error('添加备注失败');
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const getStatusColor = (status: string) => {
const colors = {
pending: 'orange',
active: 'blue',
completed: 'green',
cancelled: 'red',
refunded: 'purple',
};
return colors[status as keyof typeof colors] || 'default';
};
const getStatusText = (status: string) => {
const texts = {
pending: '等待中',
active: '通话中',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
};
return texts[status as keyof typeof texts] || status;
};
const getPaymentStatusColor = (status: string) => {
const colors = {
pending: 'orange',
paid: 'green',
refunded: 'purple',
failed: 'red',
};
return colors[status as keyof typeof colors] || 'default';
};
const getPaymentStatusText = (status: string) => {
const texts = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
failed: '支付失败',
};
return texts[status as keyof typeof texts] || status;
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
);
}
if (!call) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<div></div>
<Button type="primary" onClick={() => navigate('/calls')} style={{ marginTop: '16px' }}>
</Button>
</div>
);
}
return (
<div style={{ padding: '24px' }}>
{/* 头部导航 */}
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/calls')}
style={{ marginRight: '16px' }}
>
</Button>
<Title level={2} style={{ display: 'inline-block', margin: 0 }}>
#{call.id}
</Title>
</div>
{/* 管理员操作按钮 */}
<Space>
<Button
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
</Button>
<Button
icon={<SettingOutlined />}
onClick={() => setStatusModalVisible(true)}
>
</Button>
<Button
icon={<DollarOutlined />}
onClick={() => setRefundModalVisible(true)}
disabled={call.paymentStatus !== 'paid'}
>
退
</Button>
<Button
icon={<MessageOutlined />}
onClick={() => setAdminNoteModalVisible(true)}
>
</Button>
</Space>
</div>
{/* 系统状态提醒 */}
{call.issues && call.issues.length > 0 && (
<Alert
message="系统检测到问题"
description={call.issues.join(', ')}
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
{/* 基本信息卡片 */}
<Card title="通话信息" style={{ marginBottom: '24px' }}>
<Descriptions column={3} bordered>
<Descriptions.Item label="通话ID" span={1}>
{call.callId}
</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>
<Tag color={getStatusColor(call.status)}>
{getStatusText(call.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="支付状态" span={1}>
<Tag color={getPaymentStatusColor(call.paymentStatus)}>
{getPaymentStatusText(call.paymentStatus)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="客户姓名" span={1}>
<Space>
<UserOutlined />
{call.clientName}
</Space>
</Descriptions.Item>
<Descriptions.Item label="客户电话" span={1}>
<Space>
<PhoneOutlined />
{call.clientPhone}
</Space>
</Descriptions.Item>
<Descriptions.Item label="译员" span={1}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{call.translatorName}
</Space>
</Descriptions.Item>
<Descriptions.Item label="开始时间" span={1}>
<Space>
<ClockCircleOutlined />
{new Date(call.startTime).toLocaleString()}
</Space>
</Descriptions.Item>
<Descriptions.Item label="结束时间" span={1}>
<Space>
<ClockCircleOutlined />
{call.endTime ? new Date(call.endTime).toLocaleString() : '-'}
</Space>
</Descriptions.Item>
<Descriptions.Item label="通话时长" span={1}>
<Space>
<PhoneOutlined />
{formatTime(call.duration || 0)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="费用" span={1}>
<Space>
<DollarOutlined />
<Text strong>¥{call.cost.toFixed(2)}</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="退款金额" span={1}>
<Space>
<DollarOutlined />
<Text type={call.refundAmount > 0 ? 'danger' : 'secondary'}>
¥{call.refundAmount.toFixed(2)}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="质量评分" span={1}>
<Space>
<AuditOutlined />
<Text strong style={{ color: call.qualityScore >= 90 ? '#52c41a' : call.qualityScore >= 70 ? '#faad14' : '#ff4d4f' }}>
{call.qualityScore}/100
</Text>
</Space>
</Descriptions.Item>
</Descriptions>
{call.adminNotes && (
<div style={{ marginTop: '16px' }}>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px', background: '#f6f6f6', padding: '12px', borderRadius: '6px' }}>
{call.adminNotes}
</Paragraph>
</div>
)}
</Card>
{/* 录音播放器 */}
{call.recordingUrl && (
<Card
title={
<Space>
<SoundOutlined />
</Space>
}
style={{ marginBottom: '24px' }}
>
<div style={{ textAlign: 'center', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Button
type="primary"
size="large"
icon={isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={handlePlayPause}
style={{ marginRight: '16px' }}
>
{isPlaying ? '暂停' : '播放'}
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => message.success('录音下载中...')}
>
</Button>
</div>
<div style={{ margin: '20px 0' }}>
<Progress
percent={audioProgress}
showInfo={false}
strokeColor="#1890ff"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px' }}>
<Text type="secondary">{formatTime(currentTime)}</Text>
<Text type="secondary">{formatTime(duration)}</Text>
</div>
</div>
</div>
</Card>
)}
{/* 详细内容标签页 */}
<Card>
<Tabs defaultActiveKey="transcription">
<TabPane
tab={
<Space>
<FileTextOutlined />
</Space>
}
key="transcription"
>
<div style={{ minHeight: '200px' }}>
{call.transcription ? (
<Paragraph>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
{call.transcription}
</pre>
</Paragraph>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '50px' }}>
</div>
)}
</div>
</TabPane>
<TabPane
tab={
<Space>
<TranslationOutlined />
</Space>
}
key="translation"
>
<div style={{ minHeight: '200px' }}>
{call.translation ? (
<Paragraph>{call.translation}</Paragraph>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '50px' }}>
</div>
)}
</div>
</TabPane>
<TabPane
tab={
<Space>
<StarOutlined />
</Space>
}
key="rating"
>
<div style={{ minHeight: '200px', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Text strong></Text>
<Rate disabled value={call.rating} style={{ marginLeft: '8px' }} />
{call.rating && (
<Text style={{ marginLeft: '8px' }}>
({call.rating}/5 )
</Text>
)}
</div>
{call.feedback && (
<div>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px' }}>
{call.feedback}
</Paragraph>
</div>
)}
</div>
</TabPane>
<TabPane
tab={
<Space>
<AuditOutlined />
</Space>
}
key="quality"
>
<div style={{ padding: '20px' }}>
<Descriptions column={2}>
<Descriptions.Item label="质量评分">
<Progress
type="circle"
percent={call.qualityScore}
width={80}
strokeColor={call.qualityScore >= 90 ? '#52c41a' : call.qualityScore >= 70 ? '#faad14' : '#ff4d4f'}
/>
</Descriptions.Item>
<Descriptions.Item label="系统检测">
{call.issues && call.issues.length > 0 ? (
<div>
{call.issues.map((issue, index) => (
<Tag key={index} color="red" style={{ marginBottom: '4px' }}>
{issue}
</Tag>
))}
</div>
) : (
<Tag color="green"></Tag>
)}
</Descriptions.Item>
</Descriptions>
</div>
</TabPane>
</Tabs>
</Card>
{/* 编辑信息弹窗 */}
<Modal
title="编辑通话信息"
visible={editModalVisible}
onCancel={() => setEditModalVisible(false)}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleEdit}
>
<Form.Item
name="clientName"
label="客户姓名"
rules={[{ required: true, message: '请输入客户姓名' }]}
>
<Input />
</Form.Item>
<Form.Item
name="clientPhone"
label="客户电话"
rules={[{ required: true, message: '请输入客户电话' }]}
>
<Input />
</Form.Item>
<Form.Item
name="translatorName"
label="译员姓名"
>
<Input />
</Form.Item>
<Form.Item
name="cost"
label="费用"
rules={[{ required: true, message: '请输入费用' }]}
>
<Input type="number" addonAfter="元" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setEditModalVisible(false)}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 更改状态弹窗 */}
<Modal
title="更改通话状态"
visible={statusModalVisible}
onCancel={() => setStatusModalVisible(false)}
footer={null}
>
<Form
form={statusForm}
layout="vertical"
onFinish={handleStatusChange}
>
<Form.Item
name="status"
label="新状态"
rules={[{ required: true, message: '请选择状态' }]}
>
<Select>
<Option value="pending"></Option>
<Option value="active"></Option>
<Option value="completed"></Option>
<Option value="cancelled"></Option>
</Select>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setStatusModalVisible(false)}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 退款处理弹窗 */}
<Modal
title="处理退款"
visible={refundModalVisible}
onCancel={() => setRefundModalVisible(false)}
footer={null}
>
<Form
form={refundForm}
layout="vertical"
onFinish={handleRefund}
initialValues={{ amount: call.cost }}
>
<Alert
message="退款提醒"
description={`原支付金额:¥${call.cost.toFixed(2)}`}
type="info"
style={{ marginBottom: '16px' }}
/>
<Form.Item
name="amount"
label="退款金额"
rules={[
{ required: true, message: '请输入退款金额' },
{ type: 'number', min: 0, max: call.cost, message: `退款金额不能超过¥${call.cost.toFixed(2)}` }
]}
>
<Input type="number" addonAfter="元" />
</Form.Item>
<Form.Item
name="reason"
label="退款原因"
rules={[{ required: true, message: '请输入退款原因' }]}
>
<TextArea rows={3} placeholder="请输入退款原因..." />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setRefundModalVisible(false)}>
</Button>
<Button type="primary" danger htmlType="submit">
退
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 添加管理员备注弹窗 */}
<Modal
title="添加管理员备注"
visible={adminNoteModalVisible}
onCancel={() => setAdminNoteModalVisible(false)}
footer={null}
>
<Form
form={noteForm}
layout="vertical"
onFinish={handleAddAdminNote}
initialValues={{ note: call.adminNotes }}
>
<Form.Item
name="note"
label="备注内容"
rules={[{ required: true, message: '请输入备注内容' }]}
>
<TextArea rows={4} placeholder="请输入管理员备注..." />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setAdminNoteModalVisible(false)}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default CallDetail;
@@ -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;
+347
View File
@@ -0,0 +1,347 @@
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,
UserOutlined,
VideoCameraOutlined,
ReloadOutlined,
TrophyOutlined
} from '@ant-design/icons';
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' }}>
<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} style={{ marginBottom: '24px' }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总通话数"
value={data.totalCalls}
prefix={<PhoneOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="文档翻译"
value={data.totalDocuments}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="预约服务"
value={data.totalAppointments}
prefix={<CalendarOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总收入"
value={data.totalRevenue}
prefix={<DollarOutlined />}
valueStyle={{ color: '#cf1322' }}
suffix="元"
/>
</Card>
</Col>
</Row>
{/* 第二行统计 */}
<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>
);
};
export default Dashboard;
File diff suppressed because it is too large Load Diff
@@ -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%
],
};
+258
View File
@@ -0,0 +1,258 @@
// 用户相关类型
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;
userId: string;
callId: string;
clientName: string;
clientPhone: string;
type: 'ai' | 'human';
status: 'pending' | 'connecting' | 'ongoing' | 'completed' | 'failed' | 'cancelled';
sourceLanguage: string;
targetLanguage: string;
startTime: string;
endTime?: string;
duration?: number; // seconds
cost: number;
rating?: number;
feedback?: string;
translatorId?: string;
translatorName?: string;
translatorPhone?: string;
recordingUrl?: string;
transcription?: string;
translation?: string;
// 管理员字段
adminNotes?: string;
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount?: number;
qualityScore?: number;
issues?: string[];
}
// 文档翻译相关类型
export interface DocumentTranslation {
id: string;
userId: string;
fileName: string;
originalSize: number;
fileUrl: string;
translatedFileUrl?: string;
sourceLanguage: string;
targetLanguage: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
progress: number;
quality: 'basic' | 'professional' | 'premium';
urgency: 'low' | 'normal' | 'high' | 'urgent';
estimatedTime?: number; // minutes
actualTime?: number;
cost: number;
translatorId?: string;
translatorName?: string;
rating?: number;
feedback?: string;
createdAt: string;
completedAt?: string;
// 管理员字段
adminNotes?: string;
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount?: number;
qualityScore?: number;
issues?: string[];
retranslationCount?: number;
clientName?: string;
clientEmail?: string;
clientPhone?: string;
}
// 预约相关类型
export interface Appointment {
id: string;
userId: string;
translatorId?: string;
title: string;
description?: string;
type: 'interpretation' | 'translation' | 'consultation';
sourceLanguage: string;
targetLanguage: string;
startTime: string;
endTime: 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;
adminNotes?: string;
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
refundAmount?: number;
qualityScore?: number;
issues?: string[];
rating?: number;
feedback?: string;
location?: string;
urgency: 'low' | 'normal' | 'high' | 'urgent';
}
// 支付相关类型
export interface Payment {
id: string;
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;
completedAt?: string;
// 管理员字段
adminNotes?: string;
clientName?: string;
clientEmail?: string;
description?: 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响应类型
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
// 分页类型
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// 统计数据类型
export interface DashboardStats {
totalUsers: number;
totalTranslators: number;
totalCalls: number;
totalDocuments: number;
totalRevenue: number;
activeUsers: number;
onlineTranslators: number;
ongoingCalls: number;
pendingDocuments: number;
}
+295
View File
@@ -0,0 +1,295 @@
import { TranslationCall, DocumentTranslation, Appointment, ApiResponse, PaginatedResponse } from '../types';
// API基础URL配置
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000/api';
// 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 = 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: RequestOptions = {}
): Promise<ApiResponse<T>> {
try {
const { method = 'GET', headers = {}, body, params } = options;
const url = this.buildURL(endpoint, params);
const requestHeaders = {
...this.defaultHeaders,
...headers,
};
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,
};
} catch (error) {
console.error('API请求失败:', error);
return {
success: false,
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.get<TranslationCall>(`/calls/${id}`);
}
async updateCall(id: string, data: Partial<TranslationCall>): Promise<ApiResponse<TranslationCall>> {
return this.post<TranslationCall>(`/calls/${id}`, data);
}
async deleteCall(id: string): Promise<ApiResponse<boolean>> {
return this.delete<boolean>(`/calls/${id}`);
}
async processRefund(callId: string, amount: number, reason: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/calls/${callId}/refund`, { amount, reason });
}
async addCallNote(callId: string, note: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/calls/${callId}/notes`, { note });
}
// 文档翻译API
async getDocument(id: string): Promise<ApiResponse<DocumentTranslation>> {
return this.get<DocumentTranslation>(`/documents/${id}`);
}
async updateDocument(id: string, data: Partial<DocumentTranslation>): Promise<ApiResponse<DocumentTranslation>> {
return this.put<DocumentTranslation>(`/documents/${id}`, data);
}
async deleteDocument(id: string): Promise<ApiResponse<boolean>> {
return this.delete<boolean>(`/documents/${id}`);
}
async reassignTranslator(documentId: string, translatorId: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/documents/${documentId}/reassign`, { translatorId });
}
async retranslateDocument(documentId: string, quality: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/documents/${documentId}/retranslate`, { quality });
}
async addDocumentNote(documentId: string, note: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/documents/${documentId}/notes`, { note });
}
// 预约管理API
async getAppointment(id: string): Promise<ApiResponse<Appointment>> {
return this.get<Appointment>(`/appointments/${id}`);
}
async updateAppointment(id: string, data: Partial<Appointment>): Promise<ApiResponse<Appointment>> {
return this.put<Appointment>(`/appointments/${id}`, data);
}
async deleteAppointment(id: string): Promise<ApiResponse<boolean>> {
return this.delete<boolean>(`/appointments/${id}`);
}
async rescheduleAppointment(
appointmentId: string,
newStartTime: string,
newEndTime: string
): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/appointments/${appointmentId}/reschedule`, { newStartTime, newEndTime });
}
async reassignAppointmentTranslator(
appointmentId: string,
translatorId: string
): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/appointments/${appointmentId}/reassign`, { translatorId });
}
async addAppointmentNote(appointmentId: string, note: string): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/appointments/${appointmentId}/notes`, { note });
}
// 退款处理API
async refundPayment(paymentId: string, amount: number): Promise<ApiResponse<boolean>> {
return this.post<boolean>(`/payments/${paymentId}/refund`, { amount });
}
// 统计数据API
async getStatistics(): Promise<ApiResponse<any>> {
return this.get<any>('/statistics');
}
// 用户管理API
async getUsers(page: number = 1, pageSize: number = 10): Promise<ApiResponse<any>> {
return this.get<any>(`/users?page=${page}&pageSize=${pageSize}`);
}
async updateUser(userId: string, data: any): Promise<ApiResponse<any>> {
return this.put<any>(`/users/${userId}`, data);
}
// 译员管理API
async getTranslators(page: number = 1, pageSize: number = 10): Promise<ApiResponse<any>> {
return this.get<any>(`/translators?page=${page}&pageSize=${pageSize}`);
}
async updateTranslator(translatorId: string, data: any): Promise<ApiResponse<any>> {
return this.put<any>(`/translators/${translatorId}`, data);
}
// 系统配置API
async getSystemConfig(): Promise<ApiResponse<any>> {
return this.get<any>('/config');
}
async updateSystemConfig(config: any): Promise<ApiResponse<any>> {
return this.put<any>('/config', config);
}
}
// 导出默认API客户端实例
export const api = new ApiClient();
// 导出常用的API方法
export const {
get,
post,
put,
patch,
delete: del,
upload,
getPaginated,
} = api;
+358
View File
@@ -0,0 +1,358 @@
import { TranslationCall, DocumentTranslation, Appointment, User, Translator } from '../types';
// 模拟数据库连接和操作
export class Database {
private connected = false;
async connect(): Promise<void> {
// 模拟数据库连接
await new Promise(resolve => setTimeout(resolve, 100));
this.connected = true;
}
async disconnect(): Promise<void> {
this.connected = false;
}
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;
}
// 通话相关操作
async getCalls(params?: any): Promise<TranslationCall[]> {
await this.connect();
// 模拟获取通话列表
return [];
}
async getCallById(id: string): Promise<TranslationCall | null> {
await this.connect();
// 模拟获取单个通话
return null;
}
async createCall(data: Partial<TranslationCall>): Promise<TranslationCall> {
await this.connect();
// 模拟创建通话
const newCall: TranslationCall = {
id: `call_${Date.now()}`,
userId: data.userId || '',
callId: `CA${Date.now()}`,
clientName: data.clientName || '',
clientPhone: data.clientPhone || '',
type: data.type || 'human',
status: 'pending',
sourceLanguage: data.sourceLanguage || '',
targetLanguage: data.targetLanguage || '',
startTime: new Date().toISOString(),
cost: data.cost || 0,
paymentStatus: 'pending',
refundAmount: 0,
qualityScore: 0,
issues: [],
};
return newCall;
}
async updateCall(id: string, data: Partial<TranslationCall>): Promise<TranslationCall | null> {
await this.connect();
// 模拟更新通话
return null;
}
async deleteCall(id: string): Promise<boolean> {
await this.connect();
// 模拟删除通话
return true;
}
// 文档翻译相关操作
async getDocuments(params?: any): Promise<DocumentTranslation[]> {
await this.connect();
// 模拟获取文档列表
return [];
}
async getDocumentById(id: string): Promise<DocumentTranslation | null> {
await this.connect();
// 模拟获取单个文档
return null;
}
async createDocument(data: Partial<DocumentTranslation>): Promise<DocumentTranslation> {
await this.connect();
// 模拟创建文档翻译
const newDocument: DocumentTranslation = {
id: `doc_${Date.now()}`,
userId: data.userId || '',
fileName: data.fileName || '',
originalSize: data.originalSize || 0,
fileUrl: data.fileUrl || '',
sourceLanguage: data.sourceLanguage || '',
targetLanguage: data.targetLanguage || '',
status: 'pending',
progress: 0,
quality: data.quality || 'basic',
urgency: data.urgency || 'normal',
estimatedTime: data.estimatedTime || 0,
cost: data.cost || 0,
createdAt: new Date().toISOString(),
paymentStatus: 'pending',
refundAmount: 0,
qualityScore: 0,
issues: [],
};
return newDocument;
}
async updateDocument(id: string, data: Partial<DocumentTranslation>): Promise<DocumentTranslation | null> {
await this.connect();
// 模拟更新文档
return null;
}
async deleteDocument(id: string): Promise<boolean> {
await this.connect();
// 模拟删除文档
return true;
}
// 预约相关操作
async getAppointments(params?: any): Promise<Appointment[]> {
await this.connect();
// 模拟获取预约列表
return [];
}
async getAppointmentById(id: string): Promise<Appointment | null> {
await this.connect();
// 模拟获取单个预约
return null;
}
async createAppointment(data: Partial<Appointment>): Promise<Appointment> {
await this.connect();
// 模拟创建预约
const newAppointment: Appointment = {
id: `apt_${Date.now()}`,
userId: data.userId || '',
translatorId: data.translatorId || '',
title: data.title || '',
description: data.description || '',
type: data.type || 'interpretation',
sourceLanguage: data.sourceLanguage || '',
targetLanguage: data.targetLanguage || '',
startTime: data.startTime || new Date().toISOString(),
endTime: data.endTime || new Date().toISOString(),
status: 'pending',
cost: data.cost || 0,
reminderSent: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
clientName: data.clientName || '',
clientEmail: data.clientEmail || '',
clientPhone: data.clientPhone || '',
translatorName: data.translatorName || '',
translatorEmail: data.translatorEmail || '',
translatorPhone: data.translatorPhone || '',
paymentStatus: 'pending',
refundAmount: 0,
qualityScore: 0,
issues: [],
urgency: data.urgency || 'normal',
};
return newAppointment;
}
async updateAppointment(id: string, data: Partial<Appointment>): Promise<Appointment | null> {
await this.connect();
// 模拟更新预约
return null;
}
async deleteAppointment(id: string): Promise<boolean> {
await this.connect();
// 模拟删除预约
return true;
}
// 用户相关操作
async getUsers(params?: any): Promise<User[]> {
await this.connect();
// 模拟获取用户列表
return [];
}
async getUserById(id: string): Promise<User | null> {
await this.connect();
// 模拟获取单个用户
return null;
}
async createUser(data: Partial<User>): Promise<User> {
await this.connect();
// 模拟创建用户
const newUser: User = {
id: `user_${Date.now()}`,
username: data.username || '',
email: data.email || '',
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;
}
async updateUser(id: string, data: Partial<User>): Promise<User | null> {
await this.connect();
// 模拟更新用户
return null;
}
async deleteUser(id: string): Promise<boolean> {
await this.connect();
// 模拟删除用户
return true;
}
// 译员相关操作
async getTranslators(params?: any): Promise<Translator[]> {
await this.connect();
// 模拟获取译员列表
return [];
}
async getTranslatorById(id: string): Promise<Translator | null> {
await this.connect();
// 模拟获取单个译员
return null;
}
async createTranslator(data: Partial<Translator>): Promise<Translator> {
await this.connect();
// 模拟创建译员
const newTranslator: Translator = {
id: `translator_${Date.now()}`,
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,
certifications: data.certifications || [],
workingHours: data.workingHours || {
monday: [],
tuesday: [],
wednesday: [],
thursday: [],
friday: [],
saturday: [],
sunday: [],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return newTranslator;
}
async updateTranslator(id: string, data: Partial<Translator>): Promise<Translator | null> {
await this.connect();
// 模拟更新译员
return null;
}
async deleteTranslator(id: string): Promise<boolean> {
await this.connect();
// 模拟删除译员
return true;
}
// 统计相关操作
async getStatistics(): Promise<any> {
await this.connect();
// 模拟获取统计数据
return {
totalCalls: 0,
totalDocuments: 0,
totalAppointments: 0,
totalUsers: 0,
totalTranslators: 0,
totalRevenue: 0,
};
}
}
// 导出单例实例
export const database = new Database();
export default database;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
+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>
+324 -1
View File
@@ -12,12 +12,15 @@
"@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",
"axios": "^1.6.2",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -31,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": {
@@ -2009,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",
@@ -2238,6 +2363,15 @@
"@types/unist": "*"
}
},
"node_modules/@types/moment": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.13.0.tgz",
"integrity": "sha512-DyuyYGpV6r+4Z1bUznLi/Y7HpGn4iQ4IVcGn8zrr1P4KotKLdH0sbK1TFR6RGyX6B+G8u83wCzL+bpawKU/hdQ==",
"deprecated": "This is a stub types definition for Moment (https://github.com/moment/moment). Moment provides its own type definitions, so you don't need @types/moment installed!",
"dependencies": {
"moment": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -3186,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",
@@ -3602,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",
@@ -5444,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",
@@ -5633,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",
@@ -6342,6 +6542,14 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -6608,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",
@@ -6791,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",
@@ -7869,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",
@@ -8430,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",
@@ -8444,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",
@@ -8683,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",
+4 -1
View File
@@ -15,12 +15,15 @@
"@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",
"axios": "^1.6.2",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -34,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;
@@ -0,0 +1,530 @@
import * as React from 'react';
import { useState } from 'react';
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
DatePicker,
TimePicker,
Radio,
Divider,
Card,
Tag,
Avatar,
Alert,
} from 'antd';
import {
CalendarOutlined,
UserOutlined,
TranslationOutlined,
DollarOutlined,
ClockCircleOutlined,
VideoCameraOutlined,
PhoneOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/utils/api';
const { Option } = Select;
const { RangePicker } = TimePicker;
interface NewAppointmentModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
interface AppointmentFormData {
title: string;
description?: string;
type: 'interpretation' | 'consultation' | 'document_review';
sourceLanguage: string;
targetLanguage: string;
date: dayjs.Dayjs;
timeRange: [dayjs.Dayjs, dayjs.Dayjs];
translatorId?: string;
meetingType: 'online' | 'offline' | 'phone';
location?: string;
urgency: 'normal' | 'urgent';
notes?: string;
}
const NewAppointmentModal: React.FC<NewAppointmentModalProps> = ({
visible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(null);
const [meetingType, setMeetingType] = useState<'online' | 'offline' | 'phone'>('online');
const [estimatedCost, setEstimatedCost] = useState(0);
const [availableTranslators, setAvailableTranslators] = useState<any[]>([]);
const languages = [
{ code: 'zh-CN', name: '中文' },
{ code: 'en-US', name: '英语' },
{ code: 'ja-JP', name: '日语' },
{ code: 'ko-KR', name: '韩语' },
{ code: 'fr-FR', name: '法语' },
{ code: 'de-DE', name: '德语' },
{ code: 'es-ES', name: '西班牙语' },
{ code: 'ru-RU', name: '俄语' },
];
const translators = [
{
id: 'translator_1',
name: '李翻译',
avatar: '👨‍💼',
specialization: '商务翻译',
languages: ['zh-CN', 'en-US'],
rating: 4.9,
hourlyRate: 200,
available: true,
},
{
id: 'translator_2',
name: '王翻译',
avatar: '👩‍💼',
specialization: '法律翻译',
languages: ['zh-CN', 'ja-JP'],
rating: 4.8,
hourlyRate: 250,
available: true,
},
{
id: 'translator_3',
name: '张翻译',
avatar: '👨‍🎓',
specialization: '技术翻译',
languages: ['zh-CN', 'en-US', 'ko-KR'],
rating: 4.7,
hourlyRate: 180,
available: false,
},
];
// 计算预估费用
const calculateCost = (
type: string,
duration: number,
translatorId?: string,
urgency: string = 'normal'
) => {
const baseRates = {
interpretation: 200,
consultation: 150,
document_review: 100,
};
let rate = baseRates[type as keyof typeof baseRates] || 150;
if (translatorId) {
const translator = translators.find(t => t.id === translatorId);
if (translator) {
rate = translator.hourlyRate;
}
}
const urgencyMultiplier = urgency === 'urgent' ? 1.5 : 1.0;
return rate * duration * urgencyMultiplier;
};
const handleFormChange = () => {
const values = form.getFieldsValue();
if (values.type && values.timeRange && values.timeRange.length === 2) {
const duration = values.timeRange[1].diff(values.timeRange[0], 'hour', true);
const cost = calculateCost(values.type, duration, values.translatorId, values.urgency);
setEstimatedCost(cost);
}
};
const handleDateChange = (date: dayjs.Dayjs | null) => {
setSelectedDate(date);
if (date) {
// 模拟查询可用译员
const available = translators.filter(t => t.available);
setAvailableTranslators(available);
}
};
const disabledDate = (current: dayjs.Dayjs) => {
// 禁用过去的日期
return current && current < dayjs().startOf('day');
};
const disabledTime = () => {
const now = dayjs();
const isToday = selectedDate && selectedDate.isSame(now, 'day');
if (isToday) {
return {
disabledHours: () => {
const hours = [];
for (let i = 0; i < now.hour(); i++) {
hours.push(i);
}
return hours;
},
disabledMinutes: (selectedHour: number) => {
if (selectedHour === now.hour()) {
const minutes = [];
for (let i = 0; i <= now.minute(); i++) {
minutes.push(i);
}
return minutes;
}
return [];
},
};
}
return {};
};
const handleSubmit = async (values: AppointmentFormData) => {
try {
setLoading(true);
const startTime = values.date
.hour(values.timeRange[0].hour())
.minute(values.timeRange[0].minute());
const endTime = values.date
.hour(values.timeRange[1].hour())
.minute(values.timeRange[1].minute());
const appointmentData = {
...values,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
cost: estimatedCost,
status: 'pending',
meetingUrl: meetingType === 'online' ? `https://meet.example.com/${Date.now()}` : undefined,
};
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1500));
message.success('预约创建成功!');
form.resetFields();
setEstimatedCost(0);
setSelectedDate(null);
setAvailableTranslators([]);
onSuccess();
} catch (error) {
console.error('创建预约失败:', error);
message.error('创建预约失败,请重试');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setEstimatedCost(0);
setSelectedDate(null);
setAvailableTranslators([]);
setMeetingType('online');
onCancel();
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'interpretation':
return '🗣️';
case 'consultation':
return '💬';
case 'document_review':
return '📋';
default:
return '📅';
}
};
const getMeetingTypeIcon = (type: string) => {
switch (type) {
case 'online':
return <VideoCameraOutlined />;
case 'phone':
return <PhoneOutlined />;
case 'offline':
return <UserOutlined />;
default:
return <VideoCameraOutlined />;
}
};
return (
<Modal
title={
<Space>
<CalendarOutlined />
</Space>
}
visible={visible}
onCancel={handleCancel}
footer={null}
width={700}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormChange}
initialValues={{
type: 'interpretation',
meetingType: 'online',
urgency: 'normal',
}}
>
{/* 基本信息 */}
<Divider orientation="left"></Divider>
<Form.Item
name="title"
label="预约标题"
rules={[{ required: true, message: '请输入预约标题' }]}
>
<Input
placeholder="请输入预约标题,如:商务会议翻译"
prefix={<CalendarOutlined />}
/>
</Form.Item>
<Form.Item
name="description"
label="预约描述"
>
<Input.TextArea
rows={3}
placeholder="请详细描述预约内容、会议主题、参与人员等..."
/>
</Form.Item>
<Form.Item
name="type"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value="interpretation">
🗣 -
</Radio.Button>
<Radio.Button value="consultation">
💬 -
</Radio.Button>
<Radio.Button value="document_review">
📋 -
</Radio.Button>
</Radio.Group>
</Form.Item>
{/* 语言配置 */}
<Divider orientation="left"></Divider>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="sourceLanguage"
label="源语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择源语言' }]}
>
<Select placeholder="选择源语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="targetLanguage"
label="目标语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择目标语言' }]}
>
<Select placeholder="选择目标语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
</div>
{/* 时间安排 */}
<Divider orientation="left"></Divider>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="date"
label="预约日期"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择日期"
disabledDate={disabledDate}
onChange={handleDateChange}
/>
</Form.Item>
<Form.Item
name="timeRange"
label="时间段"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择时间段' }]}
>
<RangePicker
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
format="HH:mm"
minuteStep={15}
disabledTime={disabledTime}
/>
</Form.Item>
</div>
{/* 会议方式 */}
<Form.Item
name="meetingType"
label="会议方式"
rules={[{ required: true, message: '请选择会议方式' }]}
>
<Radio.Group
buttonStyle="solid"
onChange={(e) => setMeetingType(e.target.value)}
>
<Radio.Button value="online">
<Space>
<VideoCameraOutlined />
线
</Space>
</Radio.Button>
<Radio.Button value="phone">
<Space>
<PhoneOutlined />
</Space>
</Radio.Button>
<Radio.Button value="offline">
<Space>
<UserOutlined />
线
</Space>
</Radio.Button>
</Radio.Group>
</Form.Item>
{meetingType === 'offline' && (
<Form.Item
name="location"
label="会议地点"
rules={[{ required: true, message: '请输入会议地点' }]}
>
<Input placeholder="请输入详细的会议地点" />
</Form.Item>
)}
{/* 译员选择 */}
<Divider orientation="left"></Divider>
{selectedDate && (
<Alert
message="可用译员"
description={`${selectedDate.format('YYYY年MM月DD日')} 共有 ${availableTranslators.length} 位译员可用`}
type="info"
style={{ marginBottom: 16 }}
/>
)}
<Form.Item
name="translatorId"
label="指定译员"
>
<Select placeholder="选择译员(可选,系统会自动分配)" allowClear>
{availableTranslators.map(translator => (
<Option key={translator.id} value={translator.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span style={{ fontSize: '16px' }}>{translator.avatar}</span>
<div>
<div>{translator.name}</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{translator.specialization} {translator.rating}
</div>
</div>
</Space>
<Tag color="blue">¥{translator.hourlyRate}/</Tag>
</div>
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="urgency"
label="紧急程度"
>
<Radio.Group>
<Radio value="normal"></Radio>
<Radio value="urgent"> (+50% )</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="notes"
label="特殊要求"
>
<Input.TextArea
rows={3}
placeholder="请输入特殊要求或备注信息,如专业领域、术语要求等..."
/>
</Form.Item>
{/* 费用预估 */}
{estimatedCost > 0 && (
<div style={{
background: '#f6f6f6',
padding: '16px',
borderRadius: '6px',
marginBottom: '16px'
}}>
<Space>
<DollarOutlined style={{ color: '#1890ff' }} />
<span><strong></strong></span>
<span style={{ fontSize: '18px', color: '#1890ff', fontWeight: 'bold' }}>
¥{estimatedCost.toFixed(2)}
</span>
</Space>
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
*
</div>
</div>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={handleCancel}>
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default NewAppointmentModal;
+326
View File
@@ -0,0 +1,326 @@
import * as React from 'react';
import { useState } from 'react';
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
Radio,
InputNumber,
Switch,
Divider,
} from 'antd';
import {
PhoneOutlined,
UserOutlined,
TranslationOutlined,
DollarOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { api } from '@/utils/api';
const { Option } = Select;
interface NewCallModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
interface CallFormData {
clientName: string;
clientPhone: string;
type: 'ai' | 'human' | 'video' | 'sign';
sourceLanguage: string;
targetLanguage: string;
urgency: 'normal' | 'urgent';
estimatedDuration: number;
notes?: string;
translatorId?: string;
}
const NewCallModal: React.FC<NewCallModalProps> = ({
visible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [callType, setCallType] = useState<'ai' | 'human' | 'video' | 'sign'>('ai');
const [estimatedCost, setEstimatedCost] = useState(0);
const languages = [
{ code: 'zh-CN', name: '中文' },
{ code: 'en-US', name: '英语' },
{ code: 'ja-JP', name: '日语' },
{ code: 'ko-KR', name: '韩语' },
{ code: 'fr-FR', name: '法语' },
{ code: 'de-DE', name: '德语' },
{ code: 'es-ES', name: '西班牙语' },
{ code: 'ru-RU', name: '俄语' },
];
const translators = [
{ id: 'translator_1', name: '李翻译 - 中英专家', languages: ['zh-CN', 'en-US'] },
{ id: 'translator_2', name: '王翻译 - 日语专家', languages: ['zh-CN', 'ja-JP'] },
{ id: 'translator_3', name: '张翻译 - 多语言', languages: ['zh-CN', 'en-US', 'ko-KR'] },
];
// 计算预估费用
const calculateCost = (type: string, duration: number, urgency: string) => {
const rates = {
ai: 0.5,
human: 2.0,
video: 3.0,
sign: 4.0,
};
const baseRate = rates[type as keyof typeof rates] || 1.0;
const urgencyMultiplier = urgency === 'urgent' ? 1.5 : 1.0;
return baseRate * duration * urgencyMultiplier;
};
const handleFormChange = () => {
const values = form.getFieldsValue();
if (values.type && values.estimatedDuration) {
const cost = calculateCost(values.type, values.estimatedDuration, values.urgency || 'normal');
setEstimatedCost(cost);
}
};
const handleSubmit = async (values: CallFormData) => {
try {
setLoading(true);
// 调用API创建通话
const callData = {
...values,
cost: estimatedCost,
status: 'pending',
startTime: new Date().toISOString(),
};
// 模拟API调用
await api.initiateCall({
from: '+86123456789', // 系统电话号码
to: values.clientPhone,
sourceLanguage: values.sourceLanguage,
targetLanguage: values.targetLanguage,
type: values.type
});
message.success('通话创建成功!');
form.resetFields();
setEstimatedCost(0);
onSuccess();
} catch (error) {
console.error('创建通话失败:', error);
message.error('创建通话失败,请重试');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setEstimatedCost(0);
setCallType('ai');
onCancel();
};
return (
<Modal
title={
<Space>
<PhoneOutlined />
</Space>
}
visible={visible}
onCancel={handleCancel}
footer={null}
width={600}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormChange}
initialValues={{
type: 'ai',
urgency: 'normal',
estimatedDuration: 30,
}}
>
{/* 客户信息 */}
<Divider orientation="left"></Divider>
<Form.Item
name="clientName"
label="客户姓名"
rules={[{ required: true, message: '请输入客户姓名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入客户姓名"
/>
</Form.Item>
<Form.Item
name="clientPhone"
label="客户电话"
rules={[
{ required: true, message: '请输入客户电话' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="请输入客户电话号码"
/>
</Form.Item>
{/* 服务配置 */}
<Divider orientation="left"></Divider>
<Form.Item
name="type"
label="翻译类型"
rules={[{ required: true, message: '请选择翻译类型' }]}
>
<Radio.Group
onChange={(e) => setCallType(e.target.value)}
buttonStyle="solid"
>
<Radio.Button value="ai">🤖 AI翻译</Radio.Button>
<Radio.Button value="human">👤 </Radio.Button>
<Radio.Button value="video">📹 </Radio.Button>
<Radio.Button value="sign">🤟 </Radio.Button>
</Radio.Group>
</Form.Item>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="sourceLanguage"
label="源语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择源语言' }]}
>
<Select placeholder="选择源语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="targetLanguage"
label="目标语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择目标语言' }]}
>
<Select placeholder="选择目标语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
</div>
{/* 人工翻译时显示译员选择 */}
{(callType === 'human' || callType === 'video' || callType === 'sign') && (
<Form.Item
name="translatorId"
label="指定译员"
>
<Select placeholder="选择译员(可选,系统会自动分配)" allowClear>
{translators.map(translator => (
<Option key={translator.id} value={translator.id}>
{translator.name}
</Option>
))}
</Select>
</Form.Item>
)}
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="estimatedDuration"
label="预估时长(分钟)"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入预估时长' }]}
>
<InputNumber
min={5}
max={480}
placeholder="预估通话时长"
style={{ width: '100%' }}
addonAfter="分钟"
/>
</Form.Item>
<Form.Item
name="urgency"
label="紧急程度"
style={{ flex: 1 }}
>
<Select>
<Option value="normal"></Option>
<Option value="urgent"> (+50% )</Option>
</Select>
</Form.Item>
</div>
<Form.Item
name="notes"
label="备注说明"
>
<Input.TextArea
rows={3}
placeholder="请输入特殊要求或备注信息..."
/>
</Form.Item>
{/* 费用预估 */}
{estimatedCost > 0 && (
<div style={{
background: '#f6f6f6',
padding: '16px',
borderRadius: '6px',
marginBottom: '16px'
}}>
<Space>
<DollarOutlined style={{ color: '#1890ff' }} />
<span><strong></strong></span>
<span style={{ fontSize: '18px', color: '#1890ff', fontWeight: 'bold' }}>
¥{estimatedCost.toFixed(2)}
</span>
</Space>
</div>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={handleCancel}>
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default NewCallModal;
+442
View File
@@ -0,0 +1,442 @@
import * as React from 'react';
import { useState } from 'react';
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
Upload,
Progress,
Radio,
Divider,
Card,
Tag,
} from 'antd';
import {
FileTextOutlined,
UploadOutlined,
TranslationOutlined,
DollarOutlined,
ClockCircleOutlined,
DeleteOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
import { api } from '@/utils/api';
const { Option } = Select;
const { Dragger } = Upload;
interface NewDocumentModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
interface DocumentFormData {
sourceLanguage: string;
targetLanguage: string;
quality: 'draft' | 'professional' | 'certified';
urgency: 'normal' | 'urgent' | 'express';
notes?: string;
files: UploadFile[];
}
const NewDocumentModal: React.FC<NewDocumentModalProps> = ({
visible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [estimatedCost, setEstimatedCost] = useState(0);
const [estimatedTime, setEstimatedTime] = useState(0);
const languages = [
{ code: 'zh-CN', name: '中文' },
{ code: 'en-US', name: '英语' },
{ code: 'ja-JP', name: '日语' },
{ code: 'ko-KR', name: '韩语' },
{ code: 'fr-FR', name: '法语' },
{ code: 'de-DE', name: '德语' },
{ code: 'es-ES', name: '西班牙语' },
{ code: 'ru-RU', name: '俄语' },
];
// 计算预估费用和时间
const calculateEstimates = (files: UploadFile[], quality: string, urgency: string) => {
let totalSize = 0;
let totalPages = 0;
files.forEach(file => {
if (file.size) {
totalSize += file.size;
// 估算页数(假设每页约50KB
totalPages += Math.ceil(file.size / (50 * 1024));
}
});
// 计算费用(每页基础价格)
const baseRates = {
draft: 2.0,
professional: 5.0,
certified: 10.0,
};
const urgencyMultipliers = {
normal: 1.0,
urgent: 1.5,
express: 2.0,
};
const baseRate = baseRates[quality as keyof typeof baseRates] || 5.0;
const urgencyMultiplier = urgencyMultipliers[urgency as keyof typeof urgencyMultipliers] || 1.0;
const cost = totalPages * baseRate * urgencyMultiplier;
// 计算时间(小时)
const baseTimePerPage = quality === 'certified' ? 2 : quality === 'professional' ? 1 : 0.5;
const time = totalPages * baseTimePerPage / (urgency === 'express' ? 2 : urgency === 'urgent' ? 1.5 : 1);
return { cost, time: Math.max(1, time) };
};
const handleFormChange = () => {
const values = form.getFieldsValue();
if (fileList.length > 0 && values.quality && values.urgency) {
const { cost, time } = calculateEstimates(fileList, values.quality, values.urgency);
setEstimatedCost(cost);
setEstimatedTime(time);
}
};
const uploadProps: UploadProps = {
name: 'file',
multiple: true,
fileList,
beforeUpload: (file) => {
const isValidType = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(file.type);
if (!isValidType) {
message.error('只支持 PDF、Word、PowerPoint 和文本文件!');
return false;
}
const isLt10M = file.size! / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('文件大小不能超过 10MB');
return false;
}
return false; // 阻止自动上传
},
onChange: (info) => {
setFileList(info.fileList);
// 重新计算费用
setTimeout(handleFormChange, 100);
},
onRemove: (file) => {
const newFileList = fileList.filter(item => item.uid !== file.uid);
setFileList(newFileList);
return true;
},
};
const getFileIcon = (fileName: string) => {
const ext = fileName.split('.').pop()?.toLowerCase();
switch (ext) {
case 'pdf':
return '📄';
case 'doc':
case 'docx':
return '📝';
case 'ppt':
case 'pptx':
return '📊';
case 'txt':
return '📃';
default:
return '📄';
}
};
const handleSubmit = async (values: DocumentFormData) => {
if (fileList.length === 0) {
message.error('请至少上传一个文件!');
return;
}
try {
setLoading(true);
setUploading(true);
// 上传文件
const uploadPromises = fileList.map(async (file) => {
if (file.originFileObj) {
const uploadResult = await api.uploadFile(file.originFileObj, 'document');
return uploadResult;
}
return null;
});
const uploadResults = await Promise.all(uploadPromises);
// 创建翻译任务
for (const result of uploadResults) {
if (result?.success && result.data) {
await api.translateDocument({
fileUrl: result.data.fileUrl,
fileName: result.data.fileName,
sourceLanguage: values.sourceLanguage,
targetLanguage: values.targetLanguage,
quality: values.quality,
});
}
}
message.success('文档翻译任务创建成功!');
form.resetFields();
setFileList([]);
setEstimatedCost(0);
setEstimatedTime(0);
onSuccess();
} catch (error) {
console.error('创建翻译任务失败:', error);
message.error('创建翻译任务失败,请重试');
} finally {
setLoading(false);
setUploading(false);
}
};
const handleCancel = () => {
form.resetFields();
setFileList([]);
setEstimatedCost(0);
setEstimatedTime(0);
onCancel();
};
const formatFileSize = (size: number) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Modal
title={
<Space>
<FileTextOutlined />
</Space>
}
visible={visible}
onCancel={handleCancel}
footer={null}
width={700}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormChange}
initialValues={{
quality: 'professional',
urgency: 'normal',
}}
>
{/* 文件上传 */}
<Divider orientation="left"></Divider>
<Form.Item
label="选择文件"
required
>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<UploadOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
PDFWordPowerPoint 10MB
</p>
</Dragger>
</Form.Item>
{/* 文件列表 */}
{fileList.length > 0 && (
<div style={{ marginBottom: 16 }}>
<h4></h4>
{fileList.map(file => (
<Card
key={file.uid}
size="small"
style={{ marginBottom: 8 }}
bodyStyle={{ padding: '8px 12px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span style={{ fontSize: '16px' }}>{getFileIcon(file.name)}</span>
<span>{file.name}</span>
<Tag color="blue">{formatFileSize(file.size || 0)}</Tag>
</Space>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => {
const newFileList = fileList.filter(item => item.uid !== file.uid);
setFileList(newFileList);
}}
/>
</div>
</Card>
))}
</div>
)}
{/* 翻译配置 */}
<Divider orientation="left"></Divider>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="sourceLanguage"
label="源语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择源语言' }]}
>
<Select placeholder="选择源语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="targetLanguage"
label="目标语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择目标语言' }]}
>
<Select placeholder="选择目标语言">
{languages.map(lang => (
<Option key={lang.code} value={lang.code}>
{lang.name}
</Option>
))}
</Select>
</Form.Item>
</div>
<Form.Item
name="quality"
label="翻译质量"
rules={[{ required: true, message: '请选择翻译质量' }]}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value="draft">
📝 稿 -
</Radio.Button>
<Radio.Button value="professional">
💼 -
</Radio.Button>
<Radio.Button value="certified">
🏆 -
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
name="urgency"
label="交付时间"
rules={[{ required: true, message: '请选择交付时间' }]}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value="normal">
🕐 -
</Radio.Button>
<Radio.Button value="urgent">
- 50%
</Radio.Button>
<Radio.Button value="express">
🚀 - 100%
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
name="notes"
label="特殊要求"
>
<Input.TextArea
rows={3}
placeholder="请输入特殊要求或备注信息,如专业术语、格式要求等..."
/>
</Form.Item>
{/* 费用和时间预估 */}
{estimatedCost > 0 && (
<div style={{
background: '#f6f6f6',
padding: '16px',
borderRadius: '6px',
marginBottom: '16px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<Space>
<DollarOutlined style={{ color: '#1890ff' }} />
<span><strong></strong></span>
<span style={{ fontSize: '18px', color: '#1890ff', fontWeight: 'bold' }}>
¥{estimatedCost.toFixed(2)}
</span>
</Space>
<Space>
<ClockCircleOutlined style={{ color: '#52c41a' }} />
<span><strong></strong></span>
<span style={{ fontSize: '16px', color: '#52c41a', fontWeight: 'bold' }}>
{estimatedTime.toFixed(1)}
</span>
</Space>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
*
</div>
</div>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={handleCancel}>
</Button>
<Button
type="primary"
htmlType="submit"
loading={loading}
disabled={fileList.length === 0}
>
{uploading ? '上传中...' : '创建翻译任务'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default NewDocumentModal;
+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;
+36 -37
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,38 +22,6 @@ const MobileNavigation: FC = () => {
navigate(path);
};
return (
<div style={styles.container}>
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<button
key={item.path}
style={{
...styles.navItem,
...(isActive ? styles.activeNavItem : {}),
}}
onClick={() => handleNavigation(item.path)}
>
<span style={{
...styles.icon,
...(isActive ? styles.activeIcon : {}),
}}>
{item.icon}
</span>
<span style={{
...styles.label,
...(isActive ? styles.activeLabel : {}),
}}>
{item.label}
</span>
</button>
);
})}
</div>
);
};
const styles = {
container: {
display: 'flex',
@@ -109,4 +76,36 @@ const styles = {
},
};
return (
<div style={styles.container}>
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<button
key={item.path}
style={{
...styles.navItem,
...(isActive ? styles.activeNavItem : {}),
}}
onClick={() => handleNavigation(item.path)}
>
<span style={{
...styles.icon,
...(isActive ? styles.activeIcon : {}),
}}>
{item.icon}
</span>
<span style={{
...styles.label,
...(isActive ? styles.activeLabel : {}),
}}>
{item.label}
</span>
</button>
);
})}
</div>
);
};
export default MobileNavigation;
+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>
);
@@ -0,0 +1,654 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Descriptions,
Button,
Tag,
Typography,
Space,
Modal,
Input,
message,
Spin,
Calendar,
Badge,
Avatar,
Timeline,
Tabs,
Form,
DatePicker,
Select,
Divider,
} from 'antd';
import {
ArrowLeftOutlined,
CalendarOutlined,
ClockCircleOutlined,
UserOutlined,
PhoneOutlined,
VideoCameraOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
MessageOutlined,
LinkOutlined,
DollarOutlined,
TranslationOutlined,
} from '@ant-design/icons';
import { Appointment } from '@/types';
import { database } from '@/utils/database';
import { api } from '@/utils/api';
import dayjs from 'dayjs';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
const { Option } = Select;
interface AppointmentDetailProps {}
const AppointmentDetail: React.FC<AppointmentDetailProps> = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [appointment, setAppointment] = useState<Appointment | null>(null);
const [loading, setLoading] = useState(true);
const [editModalVisible, setEditModalVisible] = useState(false);
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
if (id) {
loadAppointmentDetails();
}
}, [id]);
const loadAppointmentDetails = async () => {
try {
setLoading(true);
await database.connect();
// 模拟获取预约详情
const mockAppointment: Appointment = {
id: id!,
userId: 'user_1',
translatorId: 'translator_1',
title: '商务会议翻译',
description: '重要客户会议,需要专业的商务翻译服务,涉及合同条款和技术细节讨论。',
type: 'human',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
startTime: '2024-01-20T14:00:00Z',
endTime: '2024-01-20T16:00:00Z',
status: 'confirmed',
cost: 200.00,
meetingUrl: 'https://meet.example.com/room/abc123',
notes: '客户要求准时开始,请提前5分钟进入会议室',
reminderSent: true,
createdAt: '2024-01-15T10:00:00Z',
updatedAt: '2024-01-15T10:00:00Z',
};
setAppointment(mockAppointment);
// 填充表单数据
form.setFieldsValue({
title: mockAppointment.title,
description: mockAppointment.description,
type: mockAppointment.type,
sourceLanguage: mockAppointment.sourceLanguage,
targetLanguage: mockAppointment.targetLanguage,
startTime: dayjs(mockAppointment.startTime),
endTime: dayjs(mockAppointment.endTime),
notes: mockAppointment.notes,
});
} catch (error) {
console.error('加载预约详情失败:', error);
message.error('加载预约详情失败');
} finally {
setLoading(false);
}
};
const handleEdit = async (values: any) => {
if (!appointment) return;
try {
const updatedAppointment = {
...appointment,
...values,
startTime: values.startTime.toISOString(),
endTime: values.endTime.toISOString(),
updatedAt: new Date().toISOString(),
};
setAppointment(updatedAppointment);
setEditModalVisible(false);
message.success('预约信息更新成功');
} catch (error) {
message.error('更新预约信息失败');
}
};
const handleCancel = async () => {
if (!appointment) return;
try {
const updatedAppointment = {
...appointment,
status: 'cancelled' as const,
updatedAt: new Date().toISOString(),
};
setAppointment(updatedAppointment);
setCancelModalVisible(false);
message.success('预约已取消');
} catch (error) {
message.error('取消预约失败');
}
};
const handleJoinMeeting = () => {
if (appointment?.meetingUrl) {
window.open(appointment.meetingUrl, '_blank');
} else {
message.warning('会议链接不可用');
}
};
const getStatusColor = (status: string) => {
const colors = {
scheduled: 'orange',
confirmed: 'blue',
cancelled: 'red',
completed: 'green',
};
return colors[status as keyof typeof colors] || 'default';
};
const getStatusText = (status: string) => {
const texts = {
scheduled: '已安排',
confirmed: '已确认',
cancelled: '已取消',
completed: '已完成',
};
return texts[status as keyof typeof texts] || status;
};
const getTypeIcon = (type: string) => {
const icons = {
ai: '🤖',
human: '👤',
video: '📹',
sign: '🤟',
};
return icons[type as keyof typeof icons] || '📞';
};
const getTypeText = (type: string) => {
const texts = {
ai: 'AI翻译',
human: '人工翻译',
video: '视频通话',
sign: '手语翻译',
};
return texts[type as keyof typeof texts] || type;
};
const formatDateTime = (dateTime: string) => {
return dayjs(dateTime).format('YYYY-MM-DD HH:mm');
};
const getDuration = () => {
if (!appointment) return '';
const start = dayjs(appointment.startTime);
const end = dayjs(appointment.endTime);
const duration = end.diff(start, 'minute');
const hours = Math.floor(duration / 60);
const minutes = duration % 60;
return hours > 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
};
const getTimelineData = () => {
if (!appointment) return [];
const timeline = [
{
color: 'green',
children: (
<div>
<div><strong></strong></div>
<div>{formatDateTime(appointment.createdAt)}</div>
</div>
),
},
];
if (appointment.status === 'confirmed') {
timeline.push({
color: 'blue',
children: (
<div>
<div><strong></strong></div>
<div>{formatDateTime(appointment.updatedAt)}</div>
</div>
),
});
}
if (appointment.reminderSent) {
timeline.push({
color: 'orange',
children: (
<div>
<div><strong></strong></div>
<div>24</div>
</div>
),
});
}
if (appointment.status === 'completed') {
timeline.push({
color: 'green',
children: (
<div>
<div><strong></strong></div>
<div>{formatDateTime(appointment.endTime)}</div>
</div>
),
});
}
if (appointment.status === 'cancelled') {
timeline.push({
color: 'red',
children: (
<div>
<div><strong></strong></div>
<div>{formatDateTime(appointment.updatedAt)}</div>
</div>
),
});
}
return timeline;
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
);
}
if (!appointment) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<div></div>
<Button type="primary" onClick={() => navigate('/appointments')} style={{ marginTop: '16px' }}>
</Button>
</div>
);
}
const isUpcoming = dayjs(appointment.startTime).isAfter(dayjs());
const canEdit = appointment.status !== 'cancelled' && appointment.status !== 'completed';
const canJoin = appointment.status === 'confirmed' && appointment.meetingUrl &&
dayjs().isAfter(dayjs(appointment.startTime).subtract(5, 'minute')) &&
dayjs().isBefore(dayjs(appointment.endTime));
return (
<div style={{ padding: '24px' }}>
{/* 头部导航 */}
<div style={{ marginBottom: '24px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/appointments')}
style={{ marginRight: '16px' }}
>
</Button>
<Title level={2} style={{ display: 'inline-block', margin: 0 }}>
#{appointment.id}
</Title>
</div>
{/* 快速操作按钮 */}
<Card style={{ marginBottom: '24px' }}>
<Space>
{canJoin && (
<Button
type="primary"
size="large"
icon={<VideoCameraOutlined />}
onClick={handleJoinMeeting}
>
</Button>
)}
{canEdit && (
<Button
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
</Button>
)}
{canEdit && (
<Button
danger
icon={<DeleteOutlined />}
onClick={() => setCancelModalVisible(true)}
>
</Button>
)}
{appointment.meetingUrl && (
<Button
icon={<LinkOutlined />}
onClick={() => navigator.clipboard.writeText(appointment.meetingUrl!)}
>
</Button>
)}
</Space>
</Card>
{/* 基本信息卡片 */}
<Card title="预约信息" style={{ marginBottom: '24px' }}>
<Descriptions column={2} bordered>
<Descriptions.Item label="预约标题" span={2}>
<Text strong style={{ fontSize: '16px' }}>{appointment.title}</Text>
</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>
<Tag color={getStatusColor(appointment.status)} icon={
appointment.status === 'confirmed' ? <CheckCircleOutlined /> :
appointment.status === 'cancelled' ? <ExclamationCircleOutlined /> :
<ClockCircleOutlined />
}>
{getStatusText(appointment.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="服务类型" span={1}>
<Space>
<span>{getTypeIcon(appointment.type)}</span>
{getTypeText(appointment.type)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="语言对" span={1}>
<Tag color="blue">{appointment.sourceLanguage}</Tag>
<span style={{ margin: '0 8px' }}></span>
<Tag color="green">{appointment.targetLanguage}</Tag>
</Descriptions.Item>
<Descriptions.Item label="费用" span={1}>
<Space>
<DollarOutlined />
<Text strong>¥{appointment.cost.toFixed(2)}</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="开始时间" span={1}>
<Space>
<CalendarOutlined />
{formatDateTime(appointment.startTime)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="结束时间" span={1}>
<Space>
<CalendarOutlined />
{formatDateTime(appointment.endTime)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="持续时间" span={1}>
<Space>
<ClockCircleOutlined />
{getDuration()}
</Space>
</Descriptions.Item>
<Descriptions.Item label="译员" span={1}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{appointment.translatorId || '待分配'}
</Space>
</Descriptions.Item>
{appointment.meetingUrl && (
<Descriptions.Item label="会议链接" span={2}>
<Space>
<LinkOutlined />
<a href={appointment.meetingUrl} target="_blank" rel="noopener noreferrer">
{appointment.meetingUrl}
</a>
</Space>
</Descriptions.Item>
)}
</Descriptions>
{appointment.description && (
<div style={{ marginTop: '16px' }}>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px' }}>
{appointment.description}
</Paragraph>
</div>
)}
{appointment.notes && (
<div style={{ marginTop: '16px' }}>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px' }}>
{appointment.notes}
</Paragraph>
</div>
)}
</Card>
{/* 详细信息标签页 */}
<Card>
<Tabs defaultActiveKey="timeline">
<TabPane
tab={
<Space>
<ClockCircleOutlined />
线
</Space>
}
key="timeline"
>
<div style={{ padding: '20px' }}>
<Timeline items={getTimelineData()} />
</div>
</TabPane>
<TabPane
tab={
<Space>
<MessageOutlined />
</Space>
}
key="communication"
>
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
</div>
</TabPane>
<TabPane
tab={
<Space>
<TranslationOutlined />
</Space>
}
key="service"
>
<div style={{ padding: '20px' }}>
<Descriptions column={1}>
<Descriptions.Item label="服务时长">
{getDuration()}
</Descriptions.Item>
<Descriptions.Item label="服务费用">
¥{appointment.cost.toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="付费状态">
<Tag color="green"></Tag>
</Descriptions.Item>
<Descriptions.Item label="提醒设置">
{appointment.reminderSent ? (
<Tag color="green"></Tag>
) : (
<Tag color="orange"></Tag>
)}
</Descriptions.Item>
</Descriptions>
</div>
</TabPane>
</Tabs>
</Card>
{/* 编辑预约弹窗 */}
<Modal
title="编辑预约"
visible={editModalVisible}
onCancel={() => setEditModalVisible(false)}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleEdit}
>
<Form.Item
name="title"
label="预约标题"
rules={[{ required: true, message: '请输入预约标题' }]}
>
<Input placeholder="请输入预约标题" />
</Form.Item>
<Form.Item
name="description"
label="预约描述"
>
<TextArea rows={3} placeholder="请输入预约描述" />
</Form.Item>
<Form.Item
name="type"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
>
<Select placeholder="请选择服务类型">
<Option value="ai">AI翻译</Option>
<Option value="human"></Option>
<Option value="video"></Option>
<Option value="sign"></Option>
</Select>
</Form.Item>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="sourceLanguage"
label="源语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择源语言' }]}
>
<Select placeholder="源语言">
<Option value="zh-CN"></Option>
<Option value="en-US"></Option>
<Option value="ja-JP"></Option>
<Option value="ko-KR"></Option>
</Select>
</Form.Item>
<Form.Item
name="targetLanguage"
label="目标语言"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择目标语言' }]}
>
<Select placeholder="目标语言">
<Option value="zh-CN"></Option>
<Option value="en-US"></Option>
<Option value="ja-JP"></Option>
<Option value="ko-KR"></Option>
</Select>
</Form.Item>
</div>
<div style={{ display: 'flex', gap: '16px' }}>
<Form.Item
name="startTime"
label="开始时间"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择开始时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm"
placeholder="选择开始时间"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="endTime"
label="结束时间"
style={{ flex: 1 }}
rules={[{ required: true, message: '请选择结束时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm"
placeholder="选择结束时间"
style={{ width: '100%' }}
/>
</Form.Item>
</div>
<Form.Item
name="notes"
label="备注信息"
>
<TextArea rows={2} placeholder="请输入备注信息" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setEditModalVisible(false)}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 取消预约确认弹窗 */}
<Modal
title="取消预约"
visible={cancelModalVisible}
onOk={handleCancel}
onCancel={() => setCancelModalVisible(false)}
okText="确认取消"
cancelText="保持预约"
okButtonProps={{ danger: true }}
>
<p></p>
<p></p>
</Modal>
</div>
);
};
export default AppointmentDetail;
+504
View File
@@ -0,0 +1,504 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Descriptions,
Button,
Tag,
Rate,
Typography,
Divider,
Space,
Modal,
Input,
message,
Spin,
Timeline,
Tabs,
Avatar,
Progress,
} from 'antd';
import {
ArrowLeftOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
StarOutlined,
PhoneOutlined,
ClockCircleOutlined,
DollarOutlined,
UserOutlined,
SoundOutlined,
FileTextOutlined,
TranslationOutlined,
} from '@ant-design/icons';
import { TranslationCall } from '@/types';
import { database } from '@/utils/database';
import { api } from '@/utils/api';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
interface CallDetailProps {}
const CallDetail: React.FC<CallDetailProps> = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [call, setCall] = useState<TranslationCall | null>(null);
const [loading, setLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [ratingModalVisible, setRatingModalVisible] = useState(false);
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState('');
const [submittingRating, setSubmittingRating] = useState(false);
// 模拟音频播放状态
const [audioProgress, setAudioProgress] = useState(0);
useEffect(() => {
if (id) {
loadCallDetails();
}
}, [id]);
const loadCallDetails = async () => {
try {
setLoading(true);
await database.connect();
// 模拟获取通话详情
const mockCall: TranslationCall = {
id: id!,
userId: 'user_1',
callId: `CA${Date.now()}`,
clientName: '张先生',
clientPhone: '+86 138 0013 8000',
type: 'human',
status: 'completed',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
startTime: '2024-01-15T10:30:00Z',
endTime: '2024-01-15T10:45:00Z',
duration: 900,
cost: 45.00,
rating: 5,
feedback: '翻译非常专业,沟通顺畅,非常满意!',
translatorId: 'translator_1',
translatorName: '李翻译',
translatorPhone: '+86 138 0013 8001',
recordingUrl: '/recordings/call_123456.mp3',
transcription: '用户: 您好,我想了解一下贵公司的产品服务。\n翻译: Hello, I would like to learn about your company\'s products and services.\n客户: Thank you for your interest. Let me introduce our main products...\n翻译: 感谢您的关注。让我为您介绍我们的主要产品...',
translation: '这是一次关于产品咨询的商务通话,客户询问了公司的主要产品和服务,我们提供了详细的介绍和说明。',
};
setCall(mockCall);
setDuration(mockCall.duration || 0);
setRating(mockCall.rating || 0);
setFeedback(mockCall.feedback || '');
} catch (error) {
console.error('加载通话详情失败:', error);
message.error('加载通话详情失败');
} finally {
setLoading(false);
}
};
const handlePlayPause = () => {
setIsPlaying(!isPlaying);
if (!isPlaying) {
// 模拟音频播放
const interval = setInterval(() => {
setCurrentTime(prev => {
const newTime = prev + 1;
setAudioProgress((newTime / duration) * 100);
if (newTime >= duration) {
clearInterval(interval);
setIsPlaying(false);
setCurrentTime(0);
setAudioProgress(0);
}
return newTime;
});
}, 1000);
}
};
const handleDownloadRecording = async () => {
if (!call?.recordingUrl) return;
try {
message.info('开始下载录音文件...');
// 模拟下载
await new Promise(resolve => setTimeout(resolve, 2000));
message.success('录音文件下载完成');
} catch (error) {
message.error('下载录音文件失败');
}
};
const handleSubmitRating = async () => {
if (!call) return;
try {
setSubmittingRating(true);
await database.updateUser(call.userId, {
// 更新评分和反馈
});
setCall({
...call,
rating,
feedback,
});
setRatingModalVisible(false);
message.success('评价提交成功');
} catch (error) {
message.error('提交评价失败');
} finally {
setSubmittingRating(false);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const getStatusColor = (status: string) => {
const colors = {
pending: 'orange',
active: 'blue',
completed: 'green',
cancelled: 'red',
};
return colors[status as keyof typeof colors] || 'default';
};
const getStatusText = (status: string) => {
const texts = {
pending: '等待中',
active: '通话中',
completed: '已完成',
cancelled: '已取消',
};
return texts[status as keyof typeof texts] || status;
};
const getTypeIcon = (type: string) => {
const icons = {
ai: '🤖',
human: '👤',
video: '📹',
sign: '🤟',
};
return icons[type as keyof typeof icons] || '📞';
};
const getTypeText = (type: string) => {
const texts = {
ai: 'AI翻译',
human: '人工翻译',
video: '视频通话',
sign: '手语翻译',
};
return texts[type as keyof typeof texts] || type;
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
);
}
if (!call) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<div></div>
<Button type="primary" onClick={() => navigate('/calls')} style={{ marginTop: '16px' }}>
</Button>
</div>
);
}
return (
<div style={{ padding: '24px' }}>
{/* 头部导航 */}
<div style={{ marginBottom: '24px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/calls')}
style={{ marginRight: '16px' }}
>
</Button>
<Title level={2} style={{ display: 'inline-block', margin: 0 }}>
#{call.id}
</Title>
</div>
{/* 基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: '24px' }}>
<Descriptions column={2} bordered>
<Descriptions.Item label="通话ID" span={1}>
{call.callId}
</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>
<Tag color={getStatusColor(call.status)}>
{getStatusText(call.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="类型" span={1}>
<Space>
<span>{getTypeIcon(call.type)}</span>
{getTypeText(call.type)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="语言对" span={1}>
<Tag color="blue">{call.sourceLanguage}</Tag>
<span style={{ margin: '0 8px' }}></span>
<Tag color="green">{call.targetLanguage}</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间" span={1}>
<Space>
<ClockCircleOutlined />
{new Date(call.startTime).toLocaleString()}
</Space>
</Descriptions.Item>
<Descriptions.Item label="结束时间" span={1}>
<Space>
<ClockCircleOutlined />
{call.endTime ? new Date(call.endTime).toLocaleString() : '-'}
</Space>
</Descriptions.Item>
<Descriptions.Item label="通话时长" span={1}>
<Space>
<PhoneOutlined />
{formatTime(call.duration || 0)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="费用" span={1}>
<Space>
<DollarOutlined />
<Text strong>¥{call.cost.toFixed(2)}</Text>
</Space>
</Descriptions.Item>
{call.clientName && (
<Descriptions.Item label="客户姓名" span={1}>
<Space>
<UserOutlined />
{call.clientName}
</Space>
</Descriptions.Item>
)}
{call.clientPhone && (
<Descriptions.Item label="客户电话" span={1}>
<Space>
<PhoneOutlined />
{call.clientPhone}
</Space>
</Descriptions.Item>
)}
{call.translatorName && (
<Descriptions.Item label="译员" span={1}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{call.translatorName}
</Space>
</Descriptions.Item>
)}
{call.translatorPhone && (
<Descriptions.Item label="译员电话" span={1}>
<Space>
<PhoneOutlined />
{call.translatorPhone}
</Space>
</Descriptions.Item>
)}
</Descriptions>
</Card>
{/* 录音播放器 */}
{call.recordingUrl && (
<Card
title={
<Space>
<SoundOutlined />
</Space>
}
style={{ marginBottom: '24px' }}
>
<div style={{ textAlign: 'center', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Button
type="primary"
size="large"
icon={isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={handlePlayPause}
style={{ marginRight: '16px' }}
>
{isPlaying ? '暂停' : '播放'}
</Button>
<Button
icon={<DownloadOutlined />}
onClick={handleDownloadRecording}
>
</Button>
</div>
<div style={{ margin: '20px 0' }}>
<Progress
percent={audioProgress}
showInfo={false}
strokeColor="#1890ff"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px' }}>
<Text type="secondary">{formatTime(currentTime)}</Text>
<Text type="secondary">{formatTime(duration)}</Text>
</div>
</div>
</div>
</Card>
)}
{/* 详细内容标签页 */}
<Card>
<Tabs defaultActiveKey="transcription">
<TabPane
tab={
<Space>
<FileTextOutlined />
</Space>
}
key="transcription"
>
<div style={{ minHeight: '200px' }}>
{call.transcription ? (
<Paragraph>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
{call.transcription}
</pre>
</Paragraph>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '50px' }}>
</div>
)}
</div>
</TabPane>
<TabPane
tab={
<Space>
<TranslationOutlined />
</Space>
}
key="translation"
>
<div style={{ minHeight: '200px' }}>
{call.translation ? (
<Paragraph>{call.translation}</Paragraph>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '50px' }}>
</div>
)}
</div>
</TabPane>
<TabPane
tab={
<Space>
<StarOutlined />
</Space>
}
key="rating"
>
<div style={{ minHeight: '200px', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<Text strong></Text>
<Rate disabled value={call.rating} style={{ marginLeft: '8px' }} />
{call.rating && (
<Text style={{ marginLeft: '8px' }}>
({call.rating}/5 )
</Text>
)}
</div>
{call.feedback && (
<div>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px' }}>
{call.feedback}
</Paragraph>
</div>
)}
<div style={{ marginTop: '20px' }}>
<Button
type="primary"
icon={<StarOutlined />}
onClick={() => setRatingModalVisible(true)}
>
{call.rating ? '修改评价' : '添加评价'}
</Button>
</div>
</div>
</TabPane>
</Tabs>
</Card>
{/* 评价弹窗 */}
<Modal
title="服务评价"
visible={ratingModalVisible}
onOk={handleSubmitRating}
onCancel={() => setRatingModalVisible(false)}
confirmLoading={submittingRating}
okText="提交"
cancelText="取消"
>
<div style={{ marginBottom: '16px' }}>
<Text strong></Text>
<Rate
value={rating}
onChange={setRating}
style={{ marginLeft: '8px' }}
/>
</div>
<div>
<Text strong></Text>
<TextArea
value={feedback}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFeedback(e.target.value)}
placeholder="请分享您对本次翻译服务的意见和建议..."
rows={4}
style={{ marginTop: '8px' }}
/>
</div>
</Modal>
</div>
);
};
export default CallDetail;
+525
View File
@@ -0,0 +1,525 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Descriptions,
Button,
Tag,
Typography,
Space,
Modal,
Input,
message,
Spin,
Progress,
Tabs,
Upload,
List,
Image,
Tooltip,
Steps,
} from 'antd';
import {
ArrowLeftOutlined,
DownloadOutlined,
FileTextOutlined,
EyeOutlined,
CloudDownloadOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
FileImageOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
LoadingOutlined,
TranslationOutlined,
DollarOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { DocumentTranslation } from '@/types';
import { database } from '@/utils/database';
import { api } from '@/utils/api';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
const { Step } = Steps;
interface DocumentDetailProps {}
const DocumentDetail: React.FC<DocumentDetailProps> = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [document, setDocument] = useState<DocumentTranslation | null>(null);
const [loading, setLoading] = useState(true);
const [downloadModalVisible, setDownloadModalVisible] = useState(false);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [previewContent, setPreviewContent] = useState('');
useEffect(() => {
if (id) {
loadDocumentDetails();
}
}, [id]);
const loadDocumentDetails = async () => {
try {
setLoading(true);
await database.connect();
// 模拟获取文档详情
const mockDocument: DocumentTranslation = {
id: id!,
userId: 'user_1',
fileName: '商务合同.pdf',
fileSize: 2048576,
fileType: 'pdf',
fileUrl: '/uploads/business_contract.pdf',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
status: 'completed',
progress: 100,
createdAt: '2024-01-15T09:00:00Z',
updatedAt: '2024-01-15T09:30:00Z',
completedAt: '2024-01-15T09:30:00Z',
cost: 25.50,
translatedFileUrl: '/downloads/business_contract_en.pdf',
wordCount: 1250,
pageCount: 5,
translatorId: 'translator_2',
quality: 'professional',
};
setDocument(mockDocument);
} catch (error) {
console.error('加载文档详情失败:', error);
message.error('加载文档详情失败');
} finally {
setLoading(false);
}
};
const handleDownload = async (type: 'original' | 'translated') => {
if (!document) return;
try {
message.info('开始下载文件...');
// 模拟下载
await new Promise(resolve => setTimeout(resolve, 2000));
const fileName = type === 'original'
? document.fileName
: document.translatedFileUrl?.split('/').pop() || 'translated_file';
message.success(`${fileName} 下载完成`);
} catch (error) {
message.error('下载文件失败');
}
};
const handlePreview = async () => {
if (!document?.fileUrl) {
message.warning('暂无预览内容');
return;
}
try {
setPreviewContent('文档预览内容加载中...');
setPreviewModalVisible(true);
// 模拟加载预览内容
await new Promise(resolve => setTimeout(resolve, 1000));
setPreviewContent(`
商务合同
甲方:ABC公司
乙方:XYZ企业
第一条 合作内容
双方就以下事项达成合作协议...
第二条 合作期限
本合同有效期自2024年1月1日至2024年12月31日...
第三条 费用条款
合作费用总计人民币50万元整...
[此处为预览内容,完整内容请下载查看]
`);
} catch (error) {
message.error('加载预览失败');
}
};
const getFileIcon = (fileType: string) => {
const icons = {
pdf: <FilePdfOutlined style={{ color: '#ff4d4f' }} />,
doc: <FileWordOutlined style={{ color: '#1890ff' }} />,
docx: <FileWordOutlined style={{ color: '#1890ff' }} />,
xls: <FileExcelOutlined style={{ color: '#52c41a' }} />,
xlsx: <FileExcelOutlined style={{ color: '#52c41a' }} />,
ppt: <FilePptOutlined style={{ color: '#fa8c16' }} />,
pptx: <FilePptOutlined style={{ color: '#fa8c16' }} />,
jpg: <FileImageOutlined style={{ color: '#722ed1' }} />,
jpeg: <FileImageOutlined style={{ color: '#722ed1' }} />,
png: <FileImageOutlined style={{ color: '#722ed1' }} />,
};
return icons[fileType as keyof typeof icons] || <FileTextOutlined />;
};
const getStatusColor = (status: string) => {
const colors = {
pending: 'orange',
processing: 'blue',
completed: 'green',
failed: 'red',
cancelled: 'default',
};
return colors[status as keyof typeof colors] || 'default';
};
const getStatusText = (status: string) => {
const texts = {
pending: '等待处理',
processing: '翻译中',
completed: '已完成',
failed: '翻译失败',
cancelled: '已取消',
};
return texts[status as keyof typeof texts] || status;
};
const getStatusIcon = (status: string) => {
const icons = {
pending: <ClockCircleOutlined />,
processing: <LoadingOutlined spin />,
completed: <CheckCircleOutlined />,
failed: <ExclamationCircleOutlined />,
cancelled: <ExclamationCircleOutlined />,
};
return icons[status as keyof typeof icons] || <ClockCircleOutlined />;
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', '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 getTranslationSteps = () => {
if (!document) return [];
const steps = [
{
title: '文件上传',
status: 'finish',
icon: <CheckCircleOutlined />,
description: new Date(document.createdAt).toLocaleString(),
},
{
title: '文档分析',
status: document.status === 'pending' ? 'wait' : 'finish',
icon: document.status === 'pending' ? <ClockCircleOutlined /> : <CheckCircleOutlined />,
description: '分析文档结构和内容',
},
{
title: '翻译处理',
status: document.status === 'processing' ? 'process' :
document.status === 'completed' ? 'finish' : 'wait',
icon: document.status === 'processing' ? <LoadingOutlined spin /> :
document.status === 'completed' ? <CheckCircleOutlined /> : <ClockCircleOutlined />,
description: document.translatorId ? `译员:${document.translatorId}` : '等待分配译员',
},
{
title: '质量审核',
status: document.status === 'completed' ? 'finish' : 'wait',
icon: document.status === 'completed' ? <CheckCircleOutlined /> : <ClockCircleOutlined />,
description: document.status === 'completed' ? '审核完成' : '等待审核',
},
{
title: '完成交付',
status: document.status === 'completed' ? 'finish' : 'wait',
icon: document.status === 'completed' ? <CheckCircleOutlined /> : <ClockCircleOutlined />,
description: document.completedAt ? new Date(document.completedAt).toLocaleString() : '等待完成',
},
];
return steps;
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
);
}
if (!document) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<div></div>
<Button type="primary" onClick={() => navigate('/documents')} style={{ marginTop: '16px' }}>
</Button>
</div>
);
}
return (
<div style={{ padding: '24px' }}>
{/* 头部导航 */}
<div style={{ marginBottom: '24px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/documents')}
style={{ marginRight: '16px' }}
>
</Button>
<Title level={2} style={{ display: 'inline-block', margin: 0 }}>
#{document.id}
</Title>
</div>
{/* 基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: '24px' }}>
<Descriptions column={2} bordered>
<Descriptions.Item label="文件名" span={1}>
<Space>
{getFileIcon(document.fileType)}
{document.fileName}
</Space>
</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>
<Space>
{getStatusIcon(document.status)}
<Tag color={getStatusColor(document.status)}>
{getStatusText(document.status)}
</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="文件大小" span={1}>
{formatFileSize(document.fileSize)}
</Descriptions.Item>
<Descriptions.Item label="文件类型" span={1}>
<Tag>{document.fileType.toUpperCase()}</Tag>
</Descriptions.Item>
<Descriptions.Item label="语言对" span={1}>
<Tag color="blue">{document.sourceLanguage}</Tag>
<span style={{ margin: '0 8px' }}></span>
<Tag color="green">{document.targetLanguage}</Tag>
</Descriptions.Item>
<Descriptions.Item label="翻译类型" span={1}>
<Tag color="purple"></Tag>
</Descriptions.Item>
<Descriptions.Item label="上传时间" span={1}>
<Space>
<CalendarOutlined />
{new Date(document.createdAt).toLocaleString()}
</Space>
</Descriptions.Item>
<Descriptions.Item label="完成时间" span={1}>
<Space>
<CalendarOutlined />
{document.completedAt ? new Date(document.completedAt).toLocaleString() : '-'}
</Space>
</Descriptions.Item>
<Descriptions.Item label="字数统计" span={1}>
{document.wordCount ? `${document.wordCount.toLocaleString()}` : '-'}
</Descriptions.Item>
<Descriptions.Item label="页数" span={1}>
{document.pageCount ? `${document.pageCount}` : '-'}
</Descriptions.Item>
<Descriptions.Item label="费用" span={1}>
<Space>
<DollarOutlined />
<Text strong>¥{document.cost.toFixed(2)}</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label="质量等级" span={1}>
<Tag color="gold">{document.quality}</Tag>
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: '16px' }}>
<Text strong></Text>
<Paragraph style={{ marginTop: '8px' }}>
</Paragraph>
</div>
</Card>
{/* 翻译进度 */}
<Card title="翻译进度" style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: '20px' }}>
<Progress
percent={document.progress}
status={document.status === 'failed' ? 'exception' : 'normal'}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
</div>
<Steps current={getTranslationSteps().findIndex(step => step.status === 'process')}>
{getTranslationSteps().map((step, index) => (
<Step
key={index}
title={step.title}
description={step.description}
status={step.status as any}
icon={step.icon}
/>
))}
</Steps>
</Card>
{/* 操作和预览 */}
<Card title="文件操作">
<Tabs defaultActiveKey="actions">
<TabPane
tab={
<Space>
<DownloadOutlined />
</Space>
}
key="actions"
>
<div style={{ padding: '20px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4}></Title>
<Space>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => handleDownload('original')}
>
</Button>
<Button
icon={<EyeOutlined />}
onClick={handlePreview}
>
</Button>
</Space>
</div>
{document.status === 'completed' && document.translatedFileUrl && (
<div>
<Title level={4}></Title>
<Space>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
onClick={() => handleDownload('translated')}
>
</Button>
<Button
icon={<EyeOutlined />}
onClick={() => message.info('译文预览功能开发中')}
>
</Button>
</Space>
</div>
)}
</Space>
</div>
</TabPane>
<TabPane
tab={
<Space>
<TranslationOutlined />
</Space>
}
key="details"
>
<div style={{ padding: '20px' }}>
<List
itemLayout="horizontal"
dataSource={[
{
title: '译员信息',
content: document.translatorId || '未分配',
icon: <TranslationOutlined />,
},
{
title: '审核员',
content: '系统审核',
icon: <CheckCircleOutlined />,
},
{
title: '紧急程度',
content: '普通',
icon: <ClockCircleOutlined />,
},
{
title: '质量要求',
content: document.quality === 'professional' ? '专业级' :
document.quality === 'certified' ? '认证级' : '草稿级',
icon: <CheckCircleOutlined />,
},
]}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={item.icon}
title={item.title}
description={item.content}
/>
</List.Item>
)}
/>
</div>
</TabPane>
</Tabs>
</Card>
{/* 文件预览弹窗 */}
<Modal
title="文件预览"
visible={previewModalVisible}
onCancel={() => setPreviewModalVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewModalVisible(false)}>
</Button>,
<Button
key="download"
type="primary"
icon={<DownloadOutlined />}
onClick={() => handleDownload('original')}
>
</Button>,
]}
width={800}
>
<div style={{ maxHeight: '500px', overflow: 'auto' }}>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
{previewContent}
</pre>
</div>
</Modal>
</div>
);
};
export default DocumentDetail;
+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"
+264 -78
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>
<View style={styles.languageInfo}>
<Text style={styles.connectingText}>...</Text>
<Text style={styles.modeText}>{getModeTitle(callMode)}</Text>
<Text style={styles.languageText}>
{sourceLang?.flag} {sourceLang?.nativeName} {targetLang?.flag} {targetLang?.nativeName}
{sourceLang?.nativeName} {targetLang?.nativeName}
</Text>
{userAccount && (
<View style={styles.balanceInfo}>
<Text style={styles.balanceText}>
: ¥{formatCost(userAccount.balance)}
</Text>
<Text style={styles.rateText}>
: ¥{formatCost(billingService.calculateCallCost(callType, translationType, 1))}/
</Text>
</View>
<TouchableOpacity style={styles.cancelButton} onPress={() => navigation?.goBack()}>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);
@@ -229,7 +394,15 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
</Text>
</View>
</View>
<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,51 +592,58 @@ 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' }],
},
});
+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,
};
+498
View File
@@ -0,0 +1,498 @@
import { ApiResponse } from '@/types';
// API配置
interface ApiConfig {
baseUrl: string;
timeout: number;
retries: number;
}
// Twilio配置
interface TwilioConfig {
accountSid: string;
authToken: string;
phoneNumber: string;
}
// Stripe配置
interface StripeConfig {
publishableKey: string;
secretKey: string;
}
// OpenAI配置
interface OpenAIConfig {
apiKey: string;
model: string;
}
class ApiManager {
private config: ApiConfig;
private twilioConfig?: TwilioConfig;
private stripeConfig?: StripeConfig;
private openaiConfig?: OpenAIConfig;
constructor(config: ApiConfig) {
this.config = config;
}
// 配置第三方服务
configureTwilio(config: TwilioConfig) {
this.twilioConfig = config;
}
configureStripe(config: StripeConfig) {
this.stripeConfig = config;
}
configureOpenAI(config: OpenAIConfig) {
this.openaiConfig = config;
}
// 通用HTTP请求方法
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = `${this.config.baseUrl}${endpoint}`;
const defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
try {
const response = await fetch(url, {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
signal: AbortSignal.timeout(this.config.timeout),
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data.message || `HTTP ${response.status}: ${response.statusText}`,
};
}
return {
success: true,
data,
};
} catch (error) {
console.error('API请求失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '网络请求失败',
};
}
}
// GET请求
async get<T>(endpoint: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
const url = new URL(`${this.config.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return this.request<T>(url.pathname + url.search);
}
// POST请求
async post<T>(endpoint: string, data?: any): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
// PUT请求
async put<T>(endpoint: string, data?: any): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
// DELETE请求
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'DELETE',
});
}
// Twilio相关API
async initiateCall(callData: {
from: string;
to: string;
sourceLanguage: string;
targetLanguage: string;
type: 'ai' | 'human' | 'video' | 'sign';
}): Promise<ApiResponse<{ callSid: string; status: string }>> {
if (!this.twilioConfig) {
return {
success: false,
error: 'Twilio配置未初始化',
};
}
// 模拟Twilio API调用
console.log('发起Twilio通话:', callData);
// 模拟API响应
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
data: {
callSid: `CA${Date.now().toString()}`,
status: 'initiated',
},
};
}
async endCall(callSid: string): Promise<ApiResponse<{ status: string }>> {
if (!this.twilioConfig) {
return {
success: false,
error: 'Twilio配置未初始化',
};
}
console.log('结束Twilio通话:', callSid);
await new Promise(resolve => setTimeout(resolve, 500));
return {
success: true,
data: {
status: 'completed',
},
};
}
async getCallStatus(callSid: string): Promise<ApiResponse<{
status: string;
duration: number;
startTime: string;
endTime?: string;
}>> {
if (!this.twilioConfig) {
return {
success: false,
error: 'Twilio配置未初始化',
};
}
console.log('查询通话状态:', callSid);
await new Promise(resolve => setTimeout(resolve, 300));
return {
success: true,
data: {
status: 'completed',
duration: 900,
startTime: '2024-01-15T10:30:00Z',
endTime: '2024-01-15T10:45:00Z',
},
};
}
// Stripe相关API
async createPaymentIntent(paymentData: {
amount: number;
currency: string;
customerId?: string;
description?: string;
}): Promise<ApiResponse<{ clientSecret: string; paymentIntentId: string }>> {
if (!this.stripeConfig) {
return {
success: false,
error: 'Stripe配置未初始化',
};
}
console.log('创建Stripe支付意向:', paymentData);
await new Promise(resolve => setTimeout(resolve, 800));
return {
success: true,
data: {
clientSecret: `pi_${Date.now()}_secret_${Math.random().toString(36).substr(2, 9)}`,
paymentIntentId: `pi_${Date.now()}`,
},
};
}
async confirmPayment(paymentIntentId: string): Promise<ApiResponse<{
status: string;
amount: number;
currency: string;
}>> {
if (!this.stripeConfig) {
return {
success: false,
error: 'Stripe配置未初始化',
};
}
console.log('确认Stripe支付:', paymentIntentId);
await new Promise(resolve => setTimeout(resolve, 1200));
return {
success: true,
data: {
status: 'succeeded',
amount: 2500,
currency: 'cny',
},
};
}
async refundPayment(paymentIntentId: string, amount?: number): Promise<ApiResponse<{
refundId: string;
status: string;
amount: number;
}>> {
if (!this.stripeConfig) {
return {
success: false,
error: 'Stripe配置未初始化',
};
}
console.log('Stripe退款:', { paymentIntentId, amount });
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
data: {
refundId: `re_${Date.now()}`,
status: 'succeeded',
amount: amount || 2500,
},
};
}
// OpenAI相关API
async translateText(textData: {
text: string;
sourceLanguage: string;
targetLanguage: string;
context?: string;
}): Promise<ApiResponse<{ translatedText: string; confidence: number }>> {
if (!this.openaiConfig) {
return {
success: false,
error: 'OpenAI配置未初始化',
};
}
console.log('OpenAI文本翻译:', textData);
await new Promise(resolve => setTimeout(resolve, 1500));
// 模拟翻译结果
const translations: Record<string, string> = {
'zh-CN_en-US': 'Hello, this is a translated text.',
'en-US_zh-CN': '您好,这是翻译后的文本。',
'zh-CN_ja-JP': 'こんにちは、これは翻訳されたテキストです。',
};
const key = `${textData.sourceLanguage}_${textData.targetLanguage}`;
const translatedText = translations[key] || 'Translation completed.';
return {
success: true,
data: {
translatedText,
confidence: 0.95,
},
};
}
async transcribeAudio(audioData: {
audioUrl: string;
language: string;
}): Promise<ApiResponse<{ transcription: string; confidence: number }>> {
if (!this.openaiConfig) {
return {
success: false,
error: 'OpenAI配置未初始化',
};
}
console.log('OpenAI音频转录:', audioData);
await new Promise(resolve => setTimeout(resolve, 2000));
return {
success: true,
data: {
transcription: '这是转录的音频内容,包含了用户的语音信息。',
confidence: 0.92,
},
};
}
async translateDocument(documentData: {
fileUrl: string;
fileName: string;
sourceLanguage: string;
targetLanguage: string;
quality: 'draft' | 'professional' | 'certified';
}): Promise<ApiResponse<{ taskId: string; estimatedTime: number }>> {
if (!this.openaiConfig) {
return {
success: false,
error: 'OpenAI配置未初始化',
};
}
console.log('OpenAI文档翻译:', documentData);
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
data: {
taskId: `task_${Date.now()}`,
estimatedTime: 1800, // 30分钟
},
};
}
async getTranslationProgress(taskId: string): Promise<ApiResponse<{
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
translatedFileUrl?: string;
}>> {
if (!this.openaiConfig) {
return {
success: false,
error: 'OpenAI配置未初始化',
};
}
console.log('查询翻译进度:', taskId);
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟进度查询
const progress = Math.floor(Math.random() * 100);
const status = progress >= 100 ? 'completed' : 'processing';
return {
success: true,
data: {
status,
progress,
translatedFileUrl: status === 'completed' ? `/downloads/translated_${taskId}.pdf` : undefined,
},
};
}
// 文件上传
async uploadFile(file: File, type: 'document' | 'audio' | 'video'): Promise<ApiResponse<{
fileUrl: string;
fileName: string;
fileSize: number;
uploadId: string;
}>> {
console.log('上传文件:', { name: file.name, size: file.size, type });
// 模拟文件上传
await new Promise(resolve => setTimeout(resolve, 2000));
return {
success: true,
data: {
fileUrl: `/uploads/${type}/${Date.now()}_${file.name}`,
fileName: file.name,
fileSize: file.size,
uploadId: `upload_${Date.now()}`,
},
};
}
// 重试机制
async retryRequest<T>(
requestFn: () => Promise<ApiResponse<T>>,
maxRetries: number = this.config.retries
): Promise<ApiResponse<T>> {
let lastError: string = '';
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await requestFn();
if (result.success) {
return result;
}
lastError = result.error || '未知错误';
} catch (error) {
lastError = error instanceof Error ? error.message : '请求失败';
}
if (attempt < maxRetries) {
// 指数退避策略
const delay = Math.pow(2, attempt - 1) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`重试请求 (${attempt}/${maxRetries})...`);
}
}
return {
success: false,
error: `请求失败,已重试${maxRetries}次: ${lastError}`,
};
}
}
// 创建API实例
const apiConfig: ApiConfig = {
baseUrl: process.env.REACT_APP_API_URL || 'http://localhost:3001/api',
timeout: 30000,
retries: 3,
};
export const api = new ApiManager(apiConfig);
// 初始化第三方服务配置
if (process.env.REACT_APP_TWILIO_ACCOUNT_SID) {
api.configureTwilio({
accountSid: process.env.REACT_APP_TWILIO_ACCOUNT_SID,
authToken: process.env.REACT_APP_TWILIO_AUTH_TOKEN || '',
phoneNumber: process.env.REACT_APP_TWILIO_PHONE_NUMBER || '',
});
}
if (process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY) {
api.configureStripe({
publishableKey: process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY,
secretKey: process.env.REACT_APP_STRIPE_SECRET_KEY || '',
});
}
if (process.env.REACT_APP_OPENAI_API_KEY) {
api.configureOpenAI({
apiKey: process.env.REACT_APP_OPENAI_API_KEY,
model: process.env.REACT_APP_OPENAI_MODEL || 'gpt-3.5-turbo',
});
}
// 导出类型和实例
export type { ApiConfig, TwilioConfig, StripeConfig, OpenAIConfig };
export { ApiManager };
+358
View File
@@ -0,0 +1,358 @@
import { User, TranslationCall, DocumentTranslation, Appointment, Language } from '@/types';
// 模拟数据库连接配置
interface DatabaseConfig {
host: string;
port: number;
database: string;
username: string;
password: string;
}
class DatabaseManager {
private config: DatabaseConfig;
private isConnected: boolean = false;
constructor(config: DatabaseConfig) {
this.config = config;
}
// 连接数据库
async connect(): Promise<boolean> {
try {
// 模拟数据库连接
console.log('正在连接数据库...');
await new Promise(resolve => setTimeout(resolve, 1000));
this.isConnected = true;
console.log('数据库连接成功');
return true;
} catch (error) {
console.error('数据库连接失败:', error);
return false;
}
}
// 断开数据库连接
async disconnect(): Promise<void> {
this.isConnected = false;
console.log('数据库连接已断开');
}
// 检查连接状态
isDbConnected(): boolean {
return this.isConnected;
}
// 用户相关操作
async getUser(userId: string): Promise<User | null> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
// 模拟查询用户数据
const mockUser: User = {
id: userId,
name: '张三',
email: 'zhangsan@example.com',
phone: '+86 138 0013 8000',
avatar: '',
role: 'user',
status: 'active',
createdAt: '2023-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
preferences: {
language: 'zh-CN',
timezone: 'Asia/Shanghai',
notifications: {
email: true,
sms: true,
push: true,
},
theme: 'light',
},
subscription: {
id: 'sub_1',
userId,
plan: 'premium',
status: 'active',
startDate: '2023-01-15T00:00:00Z',
endDate: '2024-01-15T00:00:00Z',
features: ['ai_translation', 'human_translation', 'document_translation'],
},
};
return mockUser;
}
async updateUser(userId: string, userData: Partial<User>): Promise<boolean> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
// 模拟更新用户数据
console.log(`更新用户 ${userId} 的数据:`, userData);
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
// 通话记录相关操作
async getCallRecords(userId: string, limit: number = 20): Promise<TranslationCall[]> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
// 模拟查询通话记录
const mockCallRecords: TranslationCall[] = [
{
id: '1',
userId,
type: 'ai',
status: 'completed',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
startTime: '2024-01-15T10:30:00Z',
endTime: '2024-01-15T10:45:00Z',
duration: 900,
cost: 12.50,
rating: 5,
translatorName: 'AI翻译助手',
},
{
id: '2',
userId,
type: 'human',
status: 'completed',
sourceLanguage: 'en-US',
targetLanguage: 'ja-JP',
startTime: '2024-01-14T14:20:00Z',
endTime: '2024-01-14T14:50:00Z',
duration: 1800,
cost: 45.00,
rating: 5,
translatorName: '田中太郎',
},
];
return mockCallRecords;
}
async createCallRecord(callData: Omit<TranslationCall, 'id'>): Promise<string> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
const newId = Date.now().toString();
console.log('创建新的通话记录:', { id: newId, ...callData });
await new Promise(resolve => setTimeout(resolve, 500));
return newId;
}
// 文档翻译相关操作
async getDocuments(userId: string): Promise<DocumentTranslation[]> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
const mockDocuments: DocumentTranslation[] = [
{
id: '1',
userId,
fileName: '商业计划书.pdf',
fileSize: 2048576,
fileType: 'application/pdf',
fileUrl: '/uploads/business_plan.pdf',
translatedFileUrl: '/uploads/business_plan_en.pdf',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
status: 'completed',
progress: 100,
cost: 25.00,
pageCount: 15,
wordCount: 3500,
createdAt: '2024-01-15T09:00:00Z',
updatedAt: '2024-01-15T09:30:00Z',
completedAt: '2024-01-15T09:30:00Z',
quality: 'professional',
},
{
id: '2',
userId,
fileName: '技术文档.docx',
fileSize: 1536000,
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileUrl: '/uploads/tech_doc.docx',
sourceLanguage: 'zh-CN',
targetLanguage: 'ja-JP',
status: 'processing',
progress: 65,
cost: 18.00,
pageCount: 8,
wordCount: 2100,
createdAt: '2024-01-15T11:00:00Z',
updatedAt: '2024-01-15T11:30:00Z',
quality: 'professional',
},
];
return mockDocuments;
}
async createDocument(documentData: Omit<DocumentTranslation, 'id'>): Promise<string> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
const newId = Date.now().toString();
console.log('创建新的文档翻译记录:', { id: newId, ...documentData });
await new Promise(resolve => setTimeout(resolve, 500));
return newId;
}
// 预约相关操作
async getAppointments(userId: string): Promise<Appointment[]> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
const mockAppointments: Appointment[] = [
{
id: '1',
userId,
title: '商务会议翻译',
description: '重要商务会议,需要专业翻译',
type: 'human',
sourceLanguage: 'zh-CN',
targetLanguage: 'en-US',
startTime: '2024-01-20T14:00:00Z',
endTime: '2024-01-20T16:00:00Z',
status: 'confirmed',
cost: 200.00,
reminderSent: false,
createdAt: '2024-01-15T09:00:00Z',
updatedAt: '2024-01-15T09:00:00Z',
},
{
id: '2',
userId,
title: '医疗咨询翻译',
description: '医疗咨询预约翻译服务',
type: 'video',
sourceLanguage: 'en-US',
targetLanguage: 'zh-CN',
startTime: '2024-01-22T10:30:00Z',
endTime: '2024-01-22T11:30:00Z',
status: 'scheduled',
cost: 150.00,
reminderSent: false,
createdAt: '2024-01-15T10:00:00Z',
updatedAt: '2024-01-15T10:00:00Z',
},
];
return mockAppointments;
}
async createAppointment(appointmentData: Omit<Appointment, 'id'>): Promise<string> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
const newId = Date.now().toString();
console.log('创建新的预约记录:', { id: newId, ...appointmentData });
await new Promise(resolve => setTimeout(resolve, 500));
return newId;
}
async updateAppointment(appointmentId: string, appointmentData: Partial<Appointment>): Promise<boolean> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
console.log(`更新预约 ${appointmentId} 的数据:`, appointmentData);
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
async deleteAppointment(appointmentId: string): Promise<boolean> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
console.log(`删除预约 ${appointmentId}`);
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
// 语言相关操作
async getLanguages(): Promise<Language[]> {
const mockLanguages: Language[] = [
{
code: 'zh-CN',
name: 'Chinese (Simplified)',
level: 'native',
},
{
code: 'en-US',
name: 'English (US)',
level: 'fluent',
},
{
code: 'ja-JP',
name: 'Japanese',
level: 'fluent',
},
{
code: 'ko-KR',
name: 'Korean',
level: 'intermediate',
},
{
code: 'es-ES',
name: 'Spanish',
level: 'intermediate',
},
{
code: 'fr-FR',
name: 'French',
level: 'basic',
},
];
return mockLanguages;
}
// 统计数据
async getStatistics(userId: string): Promise<any> {
if (!this.isConnected) {
throw new Error('数据库未连接');
}
return {
totalCalls: 156,
totalMinutes: 2340,
totalDocuments: 23,
totalAppointments: 8,
monthlyStats: {
calls: 12,
documents: 5,
appointments: 3,
spending: 450.00,
},
};
}
}
// 创建数据库实例
const dbConfig: DatabaseConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'twilioapp',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'password',
};
export const database = new DatabaseManager(dbConfig);
// 导出类型
export type { DatabaseConfig };
export { DatabaseManager };
+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',