Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d22a1e94 | |||
| deb2900acc | |||
| 240dd5d2a4 | |||
| 7fcff7759d | |||
| cf40d6adeb |
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hostType": "lan",
|
||||
"lanType": "ip",
|
||||
"dev": true,
|
||||
"minify": false,
|
||||
"urlRandomness": null,
|
||||
"https": false
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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. **单元测试**: 为核心计费逻辑添加测试用例
|
||||
@@ -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
@@ -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.js(Token服务器)
|
||||
- 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
|
||||
@@ -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
@@ -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凭证
|
||||
- 实施适当的用户认证机制
|
||||
|
||||
## 扩展功能
|
||||
- 屏幕共享
|
||||
- 录制功能
|
||||
- 聊天消息
|
||||
- 用户权限管理
|
||||
- 通话质量监控
|
||||
@@ -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. 监控服务器性能
|
||||
|
||||
---
|
||||
|
||||
**注意**: 这是一个测试环境配置,生产环境需要额外的安全和性能优化措施。
|
||||
Generated
+18262
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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%
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+324
-1
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
Generated
+1446
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
支持 PDF、Word、PowerPoint、文本文件,单个文件不超过 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;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import MobileNavigation from './MobileNavigation';
|
||||
|
||||
interface MobileLayoutProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const MobileLayout: FC<MobileLayoutProps> = ({ children }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const userAgent = navigator.userAgent;
|
||||
const mobileKeywords = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
const isMobileDevice = mobileKeywords.test(userAgent);
|
||||
const isSmallScreen = window.innerWidth <= 768;
|
||||
setIsMobile(isMobileDevice || isSmallScreen);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
paddingBottom: isMobile ? '80px' : '0',
|
||||
position: 'relative' as const,
|
||||
},
|
||||
mobileHeader: {
|
||||
backgroundColor: '#1890ff',
|
||||
color: 'white',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'center' as const,
|
||||
fontSize: '18px',
|
||||
fontWeight: '600' as const,
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
// 如果不是移动设备,显示提示信息
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '40px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
maxWidth: '400px',
|
||||
}}>
|
||||
<h2 style={{ color: '#1890ff', marginBottom: '16px' }}>📱 移动端应用</h2>
|
||||
<p style={{ color: '#666', marginBottom: '20px' }}>
|
||||
此应用专为移动设备设计。请在手机或平板电脑上访问,或调整浏览器窗口大小至移动设备尺寸。
|
||||
</p>
|
||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
||||
您也可以使用浏览器的开发者工具切换到移动设备视图。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.mobileHeader}>
|
||||
Twilio 翻译应用
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{children || <Outlet />}
|
||||
</div>
|
||||
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileLayout;
|
||||
@@ -8,11 +8,10 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/mobile/home', label: '首页', icon: '🏠' },
|
||||
{ path: '/mobile/call', label: '通话', icon: '📞' },
|
||||
{ path: '/mobile/documents', label: '文档', icon: '📄' },
|
||||
{ path: '/mobile/appointments', label: '预约', icon: '📅' },
|
||||
{ path: '/mobile/settings', label: '设置', icon: '⚙️' },
|
||||
{ path: '/home', label: '首页', icon: '🏠' },
|
||||
{ path: '/call', label: '通话', icon: '📞' },
|
||||
{ path: '/documents', label: '文档', icon: '📄' },
|
||||
{ path: '/settings', label: '我的', icon: '👤' },
|
||||
];
|
||||
|
||||
const MobileNavigation: FC = () => {
|
||||
@@ -23,6 +22,60 @@ const MobileNavigation: FC = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
backgroundColor: '#fff',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
padding: '8px 4px',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
position: 'absolute' as const,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '80px',
|
||||
boxShadow: '0 -2px 4px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
},
|
||||
navItem: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
activeNavItem: {
|
||||
backgroundColor: '#f0f8ff',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '20px',
|
||||
marginBottom: '4px',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.2s ease',
|
||||
},
|
||||
activeIcon: {
|
||||
opacity: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center' as const,
|
||||
transition: 'color 0.2s ease',
|
||||
},
|
||||
activeLabel: {
|
||||
color: '#1890ff',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{navItems.map((item) => {
|
||||
@@ -55,58 +108,4 @@ const MobileNavigation: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
backgroundColor: '#fff',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
padding: '8px 4px',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
position: 'absolute' as const,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '80px',
|
||||
boxShadow: '0 -2px 4px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 1000,
|
||||
},
|
||||
navItem: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
activeNavItem: {
|
||||
backgroundColor: '#f0f8ff',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '20px',
|
||||
marginBottom: '4px',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.2s ease',
|
||||
},
|
||||
activeIcon: {
|
||||
opacity: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center' as const,
|
||||
transition: 'color 0.2s ease',
|
||||
},
|
||||
activeLabel: {
|
||||
color: '#1890ff',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
};
|
||||
|
||||
export default MobileNavigation;
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,407 @@
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
status: 'uploading' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
originalLanguage: string;
|
||||
targetLanguage: string;
|
||||
uploadTime: string;
|
||||
}
|
||||
|
||||
const MobileDocuments: FC = () => {
|
||||
const [documents, setDocuments] = useState<Document[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: '商业合同.pdf',
|
||||
size: '2.3 MB',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
originalLanguage: 'zh-CN',
|
||||
targetLanguage: 'en-US',
|
||||
uploadTime: '2024-01-15 14:30',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '技术文档.docx',
|
||||
size: '1.8 MB',
|
||||
status: 'processing',
|
||||
progress: 65,
|
||||
originalLanguage: 'en-US',
|
||||
targetLanguage: 'zh-CN',
|
||||
uploadTime: '2024-01-15 15:20',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedLanguages, setSelectedLanguages] = useState({
|
||||
from: 'zh-CN',
|
||||
to: 'en-US',
|
||||
});
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
|
||||
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'ja-JP', name: '日本語', flag: '🇯🇵' },
|
||||
{ code: 'ko-KR', name: '한국어', flag: '🇰🇷' },
|
||||
{ code: 'es-ES', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'fr-FR', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de-DE', name: 'Deutsch', flag: '🇩🇪' },
|
||||
];
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const newDocument: Document = {
|
||||
id: Date.now().toString(),
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024 / 1024).toFixed(1)} MB`,
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
originalLanguage: selectedLanguages.from,
|
||||
targetLanguage: selectedLanguages.to,
|
||||
uploadTime: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
|
||||
setDocuments(prev => [newDocument, ...prev]);
|
||||
|
||||
// 模拟上传进度
|
||||
simulateUpload(newDocument.id);
|
||||
}
|
||||
};
|
||||
|
||||
const simulateUpload = (docId: string) => {
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 15;
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === docId
|
||||
? { ...doc, status: 'processing', progress: 0 }
|
||||
: doc
|
||||
));
|
||||
clearInterval(interval);
|
||||
simulateProcessing(docId);
|
||||
} else {
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === docId
|
||||
? { ...doc, progress: Math.floor(progress) }
|
||||
: doc
|
||||
));
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const simulateProcessing = (docId: string) => {
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 10;
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === docId
|
||||
? { ...doc, status: 'completed', progress: 100 }
|
||||
: doc
|
||||
));
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === docId
|
||||
? { ...doc, progress: Math.floor(progress) }
|
||||
: doc
|
||||
));
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const getStatusText = (status: Document['status']) => {
|
||||
switch (status) {
|
||||
case 'uploading': return '上传中';
|
||||
case 'processing': return '翻译中';
|
||||
case 'completed': return '已完成';
|
||||
case 'failed': return '失败';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Document['status']) => {
|
||||
switch (status) {
|
||||
case 'uploading': return '#1890ff';
|
||||
case 'processing': return '#fa8c16';
|
||||
case 'completed': return '#52c41a';
|
||||
case 'failed': return '#ff4d4f';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const swapLanguages = () => {
|
||||
setSelectedLanguages({
|
||||
from: selectedLanguages.to,
|
||||
to: selectedLanguages.from,
|
||||
});
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
minHeight: '100%',
|
||||
},
|
||||
uploadSection: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
uploadTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: '600' as const,
|
||||
color: '#333',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
languageSelector: {
|
||||
marginBottom: '20px',
|
||||
},
|
||||
languageRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
languageSelect: {
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: 'white',
|
||||
fontSize: '14px',
|
||||
},
|
||||
swapButton: {
|
||||
margin: '0 12px',
|
||||
padding: '8px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
backgroundColor: '#f0f8ff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
},
|
||||
uploadArea: {
|
||||
border: '2px dashed #d9d9d9',
|
||||
borderRadius: '8px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.3s ease',
|
||||
},
|
||||
uploadIcon: {
|
||||
fontSize: '48px',
|
||||
marginBottom: '12px',
|
||||
color: '#1890ff',
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
uploadSubtext: {
|
||||
fontSize: '14px',
|
||||
color: '#999',
|
||||
},
|
||||
hiddenInput: {
|
||||
display: 'none',
|
||||
},
|
||||
documentsSection: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '18px',
|
||||
fontWeight: '600' as const,
|
||||
color: '#333',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
documentItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
documentIcon: {
|
||||
fontSize: '24px',
|
||||
marginRight: '12px',
|
||||
},
|
||||
documentInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
documentName: {
|
||||
fontSize: '14px',
|
||||
fontWeight: '500' as const,
|
||||
color: '#333',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
documentDetails: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
documentLanguages: {
|
||||
fontSize: '12px',
|
||||
color: '#1890ff',
|
||||
},
|
||||
documentStatus: {
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: '12px',
|
||||
fontWeight: '500' as const,
|
||||
marginBottom: '4px',
|
||||
},
|
||||
progressBar: {
|
||||
width: '60px',
|
||||
height: '4px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#1890ff',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
actionButton: {
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: '#1890ff',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '4px',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* 上传区域 */}
|
||||
<div style={styles.uploadSection}>
|
||||
<h2 style={styles.uploadTitle}>📄 文档翻译</h2>
|
||||
|
||||
{/* 语言选择 */}
|
||||
<div style={styles.languageSelector}>
|
||||
<div style={styles.languageRow}>
|
||||
<select
|
||||
style={styles.languageSelect}
|
||||
value={selectedLanguages.from}
|
||||
onChange={(e) => setSelectedLanguages({...selectedLanguages, from: e.target.value})}
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.flag} {lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button style={styles.swapButton} onClick={swapLanguages}>
|
||||
🔄
|
||||
</button>
|
||||
|
||||
<select
|
||||
style={styles.languageSelect}
|
||||
value={selectedLanguages.to}
|
||||
onChange={(e) => setSelectedLanguages({...selectedLanguages, to: e.target.value})}
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.flag} {lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<label style={styles.uploadArea}>
|
||||
<input
|
||||
type="file"
|
||||
style={styles.hiddenInput}
|
||||
accept=".pdf,.doc,.docx,.txt"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<div style={styles.uploadIcon}>📁</div>
|
||||
<div style={styles.uploadText}>点击上传文档</div>
|
||||
<div style={styles.uploadSubtext}>
|
||||
支持 PDF、DOC、DOCX、TXT 格式
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 文档列表 */}
|
||||
<div style={styles.documentsSection}>
|
||||
<h3 style={styles.sectionTitle}>我的文档</h3>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无文档,请上传您的第一个文档
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
<div key={doc.id} style={styles.documentItem}>
|
||||
<div style={styles.documentIcon}>
|
||||
{doc.name.endsWith('.pdf') ? '📄' : '📝'}
|
||||
</div>
|
||||
|
||||
<div style={styles.documentInfo}>
|
||||
<div style={styles.documentName}>{doc.name}</div>
|
||||
<div style={styles.documentDetails}>
|
||||
{doc.size} • {doc.uploadTime}
|
||||
</div>
|
||||
<div style={styles.documentLanguages}>
|
||||
{languages.find(l => l.code === doc.originalLanguage)?.flag} → {' '}
|
||||
{languages.find(l => l.code === doc.targetLanguage)?.flag}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.documentStatus}>
|
||||
<div style={{
|
||||
...styles.statusText,
|
||||
color: getStatusColor(doc.status)
|
||||
}}>
|
||||
{getStatusText(doc.status)}
|
||||
</div>
|
||||
|
||||
{doc.status !== 'completed' && (
|
||||
<div style={styles.progressBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.progressFill,
|
||||
width: `${doc.progress}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.status === 'completed' && (
|
||||
<button style={styles.actionButton}>
|
||||
下载
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileDocuments;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -3,13 +3,23 @@ import { AppLayout } from '@/components/Layout';
|
||||
import { Dashboard, UserList, CallList } from '@/pages';
|
||||
import { useAuth } from '@/store';
|
||||
|
||||
// 导入移动端页面 - 使用Web版本
|
||||
import HomeScreen from '@/screens/HomeScreen.web';
|
||||
import CallScreen from '@/screens/CallScreen.web';
|
||||
import DocumentScreen from '@/screens/DocumentScreen.web';
|
||||
import AppointmentScreen from '@/screens/AppointmentScreen.web';
|
||||
import SettingsScreen from '@/screens/SettingsScreen.web';
|
||||
import MobileNavigation from '@/components/MobileNavigation.web';
|
||||
// 导入移动端组件
|
||||
import MobileLayout from '@/components/MobileLayout';
|
||||
import MobileHome from '@/pages/mobile/Home';
|
||||
import MobileCall from '@/pages/mobile/Call';
|
||||
import MobileDocuments from '@/pages/mobile/Documents';
|
||||
import MobileSettings from '@/pages/mobile/Settings';
|
||||
import MobileRecharge from '@/pages/mobile/Recharge';
|
||||
import MobileAppointment from '@/pages/mobile/Appointment';
|
||||
|
||||
// 导入设备重定向组件
|
||||
import DeviceRedirect from '@/components/DeviceRedirect';
|
||||
|
||||
// 导入视频通话测试组件
|
||||
import VideoCallTest from '@/components/VideoCallTest';
|
||||
|
||||
// 导入视频通话页面
|
||||
import { VideoCallPage } from '@/pages/VideoCall/VideoCallPage';
|
||||
|
||||
// 私有路由组件
|
||||
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -22,7 +32,7 @@ const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return !isAuthenticated ? <>{children}</> : <Navigate to="/dashboard" replace />;
|
||||
return !isAuthenticated ? <>{children}</> : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
// 登录页面(临时占位符)
|
||||
@@ -55,25 +65,6 @@ const NotFoundPage = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端布局组件
|
||||
const MobileLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
height: 'calc(100vh - 80px)',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -87,6 +78,22 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 视频通话测试路由 - 独立访问 */}
|
||||
<Route
|
||||
path="/video-test"
|
||||
element={<VideoCallTest />}
|
||||
/>
|
||||
|
||||
{/* 根路径 - 智能重定向 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceRedirect />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 移动端路由 */}
|
||||
<Route
|
||||
path="/mobile/*"
|
||||
@@ -95,11 +102,13 @@ const AppRoutes = () => {
|
||||
<MobileLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/mobile/home" replace />} />
|
||||
<Route path="/home" element={<HomeScreen />} />
|
||||
<Route path="/call" element={<CallScreen />} />
|
||||
<Route path="/documents" element={<DocumentScreen />} />
|
||||
<Route path="/appointments" element={<AppointmentScreen />} />
|
||||
<Route path="/settings" element={<SettingsScreen />} />
|
||||
<Route path="/home" element={<MobileHome />} />
|
||||
<Route path="/call" element={<MobileCall />} />
|
||||
<Route path="/documents" element={<MobileDocuments />} />
|
||||
<Route path="/settings" element={<MobileSettings />} />
|
||||
<Route path="/recharge" element={<MobileRecharge />} />
|
||||
<Route path="/appointment" element={<MobileAppointment />} />
|
||||
<Route path="/video-test" element={<VideoCallTest />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</MobileLayout>
|
||||
@@ -114,9 +123,6 @@ const AppRoutes = () => {
|
||||
<PrivateRoute>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
{/* 默认重定向到仪表板 */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 仪表板 */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
|
||||
@@ -126,6 +132,9 @@ const AppRoutes = () => {
|
||||
{/* 通话记录 */}
|
||||
<Route path="/calls" element={<CallList />} />
|
||||
|
||||
{/* 视频通话测试 */}
|
||||
<Route path="/video-test" element={<VideoCallTest />} />
|
||||
|
||||
{/* 文档管理 - 待实现 */}
|
||||
<Route
|
||||
path="/documents"
|
||||
|
||||
+268
-82
@@ -9,20 +9,47 @@ import {
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { mockLanguages } from '@/utils/mockData';
|
||||
import { Language, CallSession } from '@/types';
|
||||
import { BillingService } from '../services/billingService';
|
||||
import { CallType, TranslationType, UserAccount, UserType } from '../types/billing';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
// 模拟语言数据
|
||||
const mockLanguages = [
|
||||
{ code: 'zh', name: '中文', nativeName: '中文' },
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
|
||||
];
|
||||
|
||||
interface Language {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
}
|
||||
|
||||
interface CallSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
sourceLanguage: string;
|
||||
targetLanguage: string;
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
duration: number;
|
||||
cost: number;
|
||||
twilioRoomId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CallScreenProps {
|
||||
route?: {
|
||||
params?: {
|
||||
mode: 'ai' | 'human' | 'video' | 'sign';
|
||||
sourceLanguage: string;
|
||||
targetLanguage: string;
|
||||
mode?: string;
|
||||
sourceLanguage?: string;
|
||||
targetLanguage?: string;
|
||||
};
|
||||
};
|
||||
navigation?: any;
|
||||
navigation?: {
|
||||
goBack: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
@@ -33,14 +60,40 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
const [callDuration, setCallDuration] = useState(0);
|
||||
const [currentCall, setCurrentCall] = useState<CallSession | null>(null);
|
||||
|
||||
// 计费相关状态
|
||||
const [userAccount, setUserAccount] = useState<UserAccount | null>(null);
|
||||
const [currentCost, setCurrentCost] = useState(0);
|
||||
const [billingService] = useState(() => BillingService.getInstance());
|
||||
const [lastBillingMinute, setLastBillingMinute] = useState(0);
|
||||
|
||||
const callTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const startTime = useRef<Date | null>(null);
|
||||
const billingInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 从路由参数获取通话配置
|
||||
const callMode = route?.params?.mode || 'ai';
|
||||
const sourceLanguage = route?.params?.sourceLanguage || 'zh';
|
||||
const targetLanguage = route?.params?.targetLanguage || 'en';
|
||||
|
||||
// 根据模式确定通话和翻译类型
|
||||
const getCallType = (mode: string): CallType => {
|
||||
return mode === 'video' || mode === 'sign' ? CallType.VIDEO : CallType.VOICE;
|
||||
};
|
||||
|
||||
const getTranslationType = (mode: string): TranslationType => {
|
||||
switch (mode) {
|
||||
case 'sign':
|
||||
return TranslationType.SIGN_LANGUAGE;
|
||||
case 'human':
|
||||
return TranslationType.HUMAN_INTERPRETER;
|
||||
default:
|
||||
return TranslationType.TEXT;
|
||||
}
|
||||
};
|
||||
|
||||
const callType = getCallType(callMode);
|
||||
const translationType = getTranslationType(callMode);
|
||||
|
||||
// 获取语言信息
|
||||
const getLanguageInfo = (code: string): Language | undefined => {
|
||||
return mockLanguages.find(lang => lang.code === code);
|
||||
@@ -49,6 +102,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
const sourceLang = getLanguageInfo(sourceLanguage);
|
||||
const targetLang = getLanguageInfo(targetLanguage);
|
||||
|
||||
// 初始化用户账户
|
||||
useEffect(() => {
|
||||
const initUserAccount = () => {
|
||||
const mockAccount: UserAccount = {
|
||||
id: 'user-123',
|
||||
userType: UserType.INDIVIDUAL,
|
||||
balance: 10000, // 100元
|
||||
};
|
||||
billingService.setUserAccount(mockAccount);
|
||||
setUserAccount(mockAccount);
|
||||
};
|
||||
|
||||
initUserAccount();
|
||||
}, [billingService]);
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟连接过程
|
||||
connectToCall();
|
||||
@@ -57,13 +125,100 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
if (callTimer.current) {
|
||||
clearInterval(callTimer.current);
|
||||
}
|
||||
if (billingInterval.current) {
|
||||
clearInterval(billingInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 检查余额
|
||||
const checkBalance = (): boolean => {
|
||||
if (!userAccount) return false;
|
||||
|
||||
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||
if (!balanceCheck.sufficient) {
|
||||
Alert.alert(
|
||||
'余额不足',
|
||||
`需要至少 ¥${(balanceCheck.requiredAmount / 100).toFixed(2)} 才能开始通话`,
|
||||
[{ text: '确定', onPress: () => navigation?.goBack() }]
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||
if (shouldWarn) {
|
||||
Alert.alert('提醒', '账户余额较低,请及时充值');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 开始计费
|
||||
const startBilling = () => {
|
||||
startTime.current = new Date();
|
||||
|
||||
// 每分钟进行计费
|
||||
billingInterval.current = setInterval(() => {
|
||||
if (startTime.current) {
|
||||
const currentMinute = Math.floor((Date.now() - startTime.current.getTime()) / 60000);
|
||||
if (currentMinute > lastBillingMinute) {
|
||||
performBilling(currentMinute);
|
||||
setLastBillingMinute(currentMinute);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
};
|
||||
|
||||
// 执行计费
|
||||
const performBilling = (minute: number) => {
|
||||
if (!userAccount) return;
|
||||
|
||||
try {
|
||||
const cost = billingService.calculateCallCost(callType, translationType, 1);
|
||||
const newTotalCost = currentCost + cost;
|
||||
|
||||
// 检查余额是否足够继续通话
|
||||
const balanceCheck = billingService.checkBalance(callType, translationType, 1);
|
||||
if (!balanceCheck.sufficient) {
|
||||
Alert.alert('余额不足', '通话即将结束', [
|
||||
{ text: '确定', onPress: () => endCall() }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 扣费
|
||||
const deductSuccess = billingService.deductBalance(cost);
|
||||
if (deductSuccess) {
|
||||
setCurrentCost(newTotalCost);
|
||||
const updatedAccount = billingService.getUserAccount();
|
||||
setUserAccount(updatedAccount);
|
||||
|
||||
const shouldWarn = billingService.shouldShowLowBalanceWarning(callType, translationType);
|
||||
if (shouldWarn) {
|
||||
Alert.alert('提醒', '账户余额较低,请及时充值');
|
||||
}
|
||||
} else {
|
||||
Alert.alert('扣费失败', '通话即将结束', [
|
||||
{ text: '确定', onPress: () => endCall() }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('计费失败:', error);
|
||||
Alert.alert('计费系统异常', '通话即将结束', [
|
||||
{ text: '确定', onPress: () => endCall() }
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const connectToCall = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
|
||||
// 检查余额
|
||||
if (!checkBalance()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟获取Twilio token和连接过程
|
||||
// const tokenResponse = await apiService.getTwilioToken(callMode);
|
||||
// const callResponse = await apiService.startCall({
|
||||
@@ -77,6 +232,7 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
setIsConnecting(false);
|
||||
setIsConnected(true);
|
||||
startCallTimer();
|
||||
startBilling(); // 开始计费
|
||||
|
||||
// 创建模拟通话会话
|
||||
const mockCall: CallSession = {
|
||||
@@ -117,10 +273,14 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatCost = (cents: number): string => {
|
||||
return (cents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
Alert.alert(
|
||||
'结束通话',
|
||||
'确定要结束当前通话吗?',
|
||||
`通话时长: ${formatDuration(callDuration)}\n费用: ¥${formatCost(currentCost)}\n确定要结束当前通话吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
@@ -139,6 +299,9 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
if (callTimer.current) {
|
||||
clearInterval(callTimer.current);
|
||||
}
|
||||
if (billingInterval.current) {
|
||||
clearInterval(billingInterval.current);
|
||||
}
|
||||
|
||||
// 在实际应用中调用API结束通话
|
||||
// if (currentCall) {
|
||||
@@ -196,19 +359,21 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#1a1a1a" />
|
||||
<View style={styles.connectingContainer}>
|
||||
<Text style={styles.connectingIcon}>📞</Text>
|
||||
<Text style={styles.connectingTitle}>正在连接...</Text>
|
||||
<Text style={styles.connectingSubtitle}>
|
||||
{getModeTitle(callMode)}
|
||||
<Text style={styles.connectingText}>正在连接...</Text>
|
||||
<Text style={styles.modeText}>{getModeTitle(callMode)}</Text>
|
||||
<Text style={styles.languageText}>
|
||||
{sourceLang?.nativeName} → {targetLang?.nativeName}
|
||||
</Text>
|
||||
<View style={styles.languageInfo}>
|
||||
<Text style={styles.languageText}>
|
||||
{sourceLang?.flag} {sourceLang?.nativeName} → {targetLang?.flag} {targetLang?.nativeName}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={() => navigation?.goBack()}>
|
||||
<Text style={styles.cancelButtonText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
{userAccount && (
|
||||
<View style={styles.balanceInfo}>
|
||||
<Text style={styles.balanceText}>
|
||||
账户余额: ¥{formatCost(userAccount.balance)}
|
||||
</Text>
|
||||
<Text style={styles.rateText}>
|
||||
费率: ¥{formatCost(billingService.calculateCallCost(callType, translationType, 1))}/分钟
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -229,7 +394,15 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
|
||||
<View style={styles.callStats}>
|
||||
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
|
||||
<Text style={styles.cost}>¥{formatCost(currentCost)}</Text>
|
||||
{userAccount && (
|
||||
<Text style={styles.balance}>
|
||||
余额: ¥{formatCost(userAccount.balance)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 视频区域 */}
|
||||
@@ -265,31 +438,31 @@ const CallScreen: React.FC<CallScreenProps> = ({ route, navigation }) => {
|
||||
{/* 控制按钮 */}
|
||||
<View style={styles.controlsContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, isMuted && styles.controlButtonActive]}
|
||||
style={[styles.controlButton, isMuted && styles.mutedButton]}
|
||||
onPress={toggleMute}
|
||||
>
|
||||
<Text style={styles.controlButtonText}>
|
||||
{isMuted ? '🔇' : '🔊'}
|
||||
{isMuted ? '🔇' : '🎤'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
>
|
||||
<Text style={styles.endCallButtonText}>📞</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{(callMode === 'video' || callMode === 'sign') && (
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, !isVideoEnabled && styles.controlButtonActive]}
|
||||
style={[styles.controlButton, !isVideoEnabled && styles.mutedButton]}
|
||||
onPress={toggleVideo}
|
||||
>
|
||||
<Text style={styles.controlButtonText}>
|
||||
{isVideoEnabled ? '📹' : '📵'}
|
||||
{isVideoEnabled ? '📹' : '📷'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, styles.endCallButton]}
|
||||
onPress={handleEndCall}
|
||||
>
|
||||
<Text style={styles.controlButtonText}>📞</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -304,82 +477,87 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
padding: 20,
|
||||
},
|
||||
connectingIcon: {
|
||||
fontSize: 80,
|
||||
marginBottom: 24,
|
||||
},
|
||||
connectingTitle: {
|
||||
connectingText: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
connectingSubtitle: {
|
||||
fontSize: 16,
|
||||
modeText: {
|
||||
fontSize: 18,
|
||||
color: '#ccc',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
languageInfo: {
|
||||
languageText: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
marginBottom: 20,
|
||||
},
|
||||
balanceInfo: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 32,
|
||||
},
|
||||
languageText: {
|
||||
balanceText: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#F44336',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: '#fff',
|
||||
rateText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#ccc',
|
||||
textAlign: 'center',
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
callInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
modeIcon: {
|
||||
fontSize: 32,
|
||||
fontSize: 24,
|
||||
marginRight: 12,
|
||||
},
|
||||
callDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
callTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 2,
|
||||
},
|
||||
languagesText: {
|
||||
fontSize: 14,
|
||||
color: '#ccc',
|
||||
},
|
||||
callStats: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
duration: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
cost: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#4CAF50',
|
||||
},
|
||||
balance: {
|
||||
fontSize: 14,
|
||||
color: '#ccc',
|
||||
},
|
||||
videoContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
videoArea: {
|
||||
flex: 1,
|
||||
@@ -388,13 +566,15 @@ const styles = StyleSheet.create({
|
||||
remoteVideo: {
|
||||
flex: 1,
|
||||
backgroundColor: '#333',
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
localVideo: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
top: 20,
|
||||
right: 20,
|
||||
width: 120,
|
||||
height: 160,
|
||||
backgroundColor: '#555',
|
||||
@@ -403,9 +583,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoPlaceholder: {
|
||||
color: '#ccc',
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
audioOnlyArea: {
|
||||
flex: 1,
|
||||
@@ -413,52 +592,59 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
audioIcon: {
|
||||
fontSize: 120,
|
||||
marginBottom: 24,
|
||||
fontSize: 80,
|
||||
marginBottom: 20,
|
||||
},
|
||||
audioTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
color: '#fff',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
waveform: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
height: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
waveBar: {
|
||||
width: 4,
|
||||
backgroundColor: '#4CAF50',
|
||||
marginHorizontal: 2,
|
||||
borderRadius: 2,
|
||||
},
|
||||
controlsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
paddingHorizontal: 40,
|
||||
paddingBottom: 40,
|
||||
gap: 40,
|
||||
},
|
||||
controlButton: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
controlButtonActive: {
|
||||
mutedButton: {
|
||||
backgroundColor: '#F44336',
|
||||
},
|
||||
controlButtonText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
endCallButton: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#F44336',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallButtonText: {
|
||||
fontSize: 32,
|
||||
transform: [{ rotate: '135deg' }],
|
||||
},
|
||||
});
|
||||
|
||||
export default CallScreen;
|
||||
export default CallScreen;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
// }
|
||||
};
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user